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,524 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Any, Literal, cast
6
+
7
+ from fastapi_streams.codegen.names import identifier, slug
8
+ from fastapi_streams.codegen.openapi import (
9
+ ContractError,
10
+ Document,
11
+ resolve_pointer,
12
+ resolve_ref,
13
+ validate_contract,
14
+ )
15
+
16
+ _REF_METADATA = {
17
+ "title",
18
+ "description",
19
+ "default",
20
+ "examples",
21
+ "example",
22
+ "deprecated",
23
+ "readOnly",
24
+ "writeOnly",
25
+ "$comment",
26
+ "externalDocs",
27
+ "xml",
28
+ }
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Scalar:
33
+ name: Literal[
34
+ "string", "integer", "number", "boolean", "null", "never", "JsonValue"
35
+ ]
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class LiteralType:
40
+ value: str | int | float | bool | None
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class EnumMember:
45
+ name: str
46
+ value: str
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class StringEnum:
51
+ members: tuple[EnumMember, ...]
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class EnumAlias:
56
+ target: str
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class EnumValue:
61
+ enum: str
62
+ member: str
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class Reference:
67
+ name: str
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class Array:
72
+ item: Type
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class Field:
77
+ name: str
78
+ type: Type
79
+ required: bool
80
+ default: LiteralType | None = None
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class Object:
85
+ fields: tuple[Field, ...]
86
+ additional: Type | None = None
87
+ additional_forbidden: bool = False
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class Union:
92
+ members: tuple[Type, ...]
93
+ discriminator: str | None = None
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class Intersection:
98
+ members: tuple[Type, ...]
99
+
100
+
101
+ type Type = (
102
+ Scalar
103
+ | LiteralType
104
+ | Reference
105
+ | Array
106
+ | Object
107
+ | Union
108
+ | Intersection
109
+ | StringEnum
110
+ | EnumAlias
111
+ | EnumValue
112
+ )
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class Declaration:
117
+ name: str
118
+ type: Type
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class Parameter:
123
+ name: str
124
+ argument: str
125
+ location: Literal["path", "query", "header"]
126
+ type: Type
127
+ required: bool
128
+ explode: bool = True
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class Body:
133
+ type: Type
134
+ required: bool
135
+ media_type: str = "application/json"
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class ErrorResponse:
140
+ status: str
141
+ type: Type
142
+ media_type: str
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class SecurityScheme:
147
+ name: str
148
+ kind: str
149
+ location: str | None
150
+ parameter: str | None
151
+
152
+
153
+ @dataclass(frozen=True)
154
+ class Operation:
155
+ operation_id: str
156
+ name: str
157
+ group: str
158
+ directory: str
159
+ path: str
160
+ method: str
161
+ parameters: tuple[Parameter, ...]
162
+ body: Body | None
163
+ event: Type
164
+ response_status: str
165
+ errors: tuple[ErrorResponse, ...]
166
+ security: tuple[dict[str, list[str]], ...]
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class Contract:
171
+ operations: tuple[Operation, ...]
172
+ declarations: tuple[Declaration, ...]
173
+ security_schemes: tuple[SecurityScheme, ...]
174
+
175
+
176
+ def build_ir(document: Document) -> Contract:
177
+ validate_contract(document)
178
+ types = _Types(document)
179
+ operations: list[Operation] = []
180
+ ids: set[str] = set()
181
+ public_names: set[str] = set()
182
+ names: set[tuple[str, str]] = set()
183
+ groups: dict[str, str] = {}
184
+ directories: dict[str, str] = {}
185
+ paths: Document = document.get("paths", {})
186
+ for path, raw_path in sorted(paths.items()):
187
+ path_item = resolve_ref(document, raw_path)
188
+ for method in (
189
+ "get",
190
+ "post",
191
+ "put",
192
+ "patch",
193
+ "delete",
194
+ "options",
195
+ "head",
196
+ "trace",
197
+ ):
198
+ if method not in path_item:
199
+ continue
200
+ raw: Document = path_item[method]
201
+ responses: Document = raw.get("responses", {})
202
+ streams: list[tuple[str, Document]] = []
203
+ errors: list[ErrorResponse] = []
204
+ for status, value in sorted(responses.items()):
205
+ response = resolve_ref(document, value)
206
+ content: Document = response.get("content", {})
207
+ if status.startswith("2") and "text/event-stream" in content:
208
+ streams.append((status, content["text/event-stream"]))
209
+ if not streams:
210
+ continue
211
+ if len(streams) != 1 or method not in {"get", "post"}:
212
+ raise ContractError(
213
+ f"Expected one GET/POST SSE success response: {method} {path}"
214
+ )
215
+ status, media = streams[0]
216
+ envelope = resolve_ref(document, media.get("itemSchema", {}))
217
+ data = resolve_ref(document, envelope.get("properties", {}).get("data", {}))
218
+ if (
219
+ data.get("contentMediaType") != "application/json"
220
+ or "contentSchema" not in data
221
+ or data.get("type") != "string"
222
+ or "data" not in envelope.get("required", [])
223
+ or envelope.get("type") != "object"
224
+ ):
225
+ raise ContractError(
226
+ f"SSE data needs application/json contentSchema: {path}"
227
+ )
228
+ operation_id: str = raw.get("operationId", "")
229
+ if not operation_id or operation_id in ids:
230
+ raise ContractError(
231
+ f"Missing or duplicate streaming operationId: {operation_id!r}"
232
+ )
233
+ ids.add(operation_id)
234
+ public_name = identifier(operation_id, upper=True)
235
+ if public_name in public_names:
236
+ raise ContractError(f"Operation identifier collision: {public_name}")
237
+ public_names.add(public_name)
238
+ tag: str = (raw.get("tags") or ["streams"])[0]
239
+ group, directory = identifier(tag), slug(tag)
240
+ if (group in groups and groups[group] != tag) or (
241
+ directory in directories and directories[directory] != tag
242
+ ):
243
+ raise ContractError(f"Tag name collision: {tag}")
244
+ if group in {"transport", "types", "client", "index"}:
245
+ raise ContractError(f"Reserved stream tag: {tag}")
246
+ groups[group], directories[directory] = tag, tag
247
+ name = identifier(operation_id)
248
+ if (group, name) in names:
249
+ raise ContractError(f"Operation name collision: {group}.{name}")
250
+ names.add((group, name))
251
+ merged: dict[tuple[str, str], Document] = {}
252
+ for value in [*path_item.get("parameters", []), *raw.get("parameters", [])]:
253
+ param = resolve_ref(document, value)
254
+ merged[(param["in"], param["name"])] = param
255
+ params: list[Parameter] = []
256
+ arguments: set[str] = set()
257
+ for (location, param_name), param in sorted(merged.items()):
258
+ if location not in {"path", "query", "header"}:
259
+ raise ContractError(f"Unsupported parameter location: {location}")
260
+ if "content" in param:
261
+ raise ContractError("Parameters with content are not supported")
262
+ style = "form" if location == "query" else "simple"
263
+ if param.get("style", style) != style or param.get(
264
+ "allowReserved", False
265
+ ):
266
+ raise ContractError(
267
+ f"Unsupported serialization for parameter {param_name}"
268
+ )
269
+ param_type = types.parse(param.get("schema", {}))
270
+ if not _parameter_type(param_type, types, arrays=location == "query"):
271
+ supported = "scalar/array" if location == "query" else "scalar"
272
+ raise ContractError(
273
+ f"Only {supported} {location} parameters are supported: "
274
+ f"{param_name}"
275
+ )
276
+ argument = identifier(param_name)
277
+ if argument in arguments:
278
+ raise ContractError(f"Parameter name collision: {argument}")
279
+ arguments.add(argument)
280
+ params.append(
281
+ Parameter(
282
+ param_name,
283
+ argument,
284
+ cast(Any, location),
285
+ param_type,
286
+ param.get("required", False),
287
+ param.get("explode", location == "query"),
288
+ )
289
+ )
290
+ expected = set(re.findall(r"\{([^{}]+)\}", path))
291
+ actual = {param.name for param in params if param.location == "path"}
292
+ if expected != actual:
293
+ raise ContractError(
294
+ f"Path placeholders do not match parameters: {path}"
295
+ )
296
+ body: Body | None = None
297
+ if "requestBody" in raw:
298
+ request = resolve_ref(document, raw["requestBody"])
299
+ content = request.get("content", {})
300
+ if method != "post" or set(content) != {"application/json"}:
301
+ raise ContractError(
302
+ "Streaming request bodies must be POST application/json"
303
+ )
304
+ if "body" in arguments:
305
+ raise ContractError(
306
+ "Parameter 'body' conflicts with the request body"
307
+ )
308
+ body = Body(
309
+ types.parse(content["application/json"].get("schema", {})),
310
+ request.get("required", False),
311
+ )
312
+ for code, value in sorted(responses.items()):
313
+ if code.startswith("2"):
314
+ continue
315
+ content = resolve_ref(document, value).get("content", {})
316
+ for media_type, error_media in sorted(content.items()):
317
+ if "schema" in error_media:
318
+ errors.append(
319
+ ErrorResponse(
320
+ code, types.parse(error_media["schema"]), media_type
321
+ )
322
+ )
323
+ operations.append(
324
+ Operation(
325
+ operation_id,
326
+ name,
327
+ group,
328
+ directory,
329
+ path,
330
+ method.upper(),
331
+ tuple(params),
332
+ body,
333
+ types.parse(data["contentSchema"]),
334
+ status,
335
+ tuple(errors),
336
+ tuple(raw.get("security", document.get("security", []))),
337
+ )
338
+ )
339
+ security: Document = document.get("components", {}).get("securitySchemes", {})
340
+ schemes = tuple(
341
+ SecurityScheme(name, scheme["type"], scheme.get("in"), scheme.get("name"))
342
+ for name, value in sorted(security.items())
343
+ for scheme in [resolve_ref(document, value)]
344
+ )
345
+ from fastapi_streams.codegen.enums import with_enums
346
+
347
+ return with_enums(Contract(tuple(operations), types.declarations(), schemes))
348
+
349
+
350
+ class _Types:
351
+ def __init__(self, document: Document) -> None:
352
+ self.document = document
353
+ self.names: dict[str, str] = {}
354
+ self.pending: dict[str, Document | bool] = {}
355
+
356
+ def parse(self, schema: Any) -> Type:
357
+ if schema is True or schema == {}:
358
+ return Scalar("JsonValue")
359
+ if schema is False:
360
+ return Scalar("never")
361
+ if not isinstance(schema, dict):
362
+ raise ContractError(f"Expected a Schema Object, got {schema!r}")
363
+ node = cast(Document, schema)
364
+ if "$ref" in node:
365
+ ref: str = node["$ref"]
366
+ if not ref.startswith("#/components/schemas/"):
367
+ raise ContractError(
368
+ f"Type references must point to components/schemas: {ref}"
369
+ )
370
+ constraints = sorted(
371
+ key
372
+ for key in node
373
+ if key != "$ref"
374
+ and key not in _REF_METADATA
375
+ and not key.startswith("x-")
376
+ )
377
+ if constraints:
378
+ raise ContractError(
379
+ f"Unsupported constraints alongside $ref {ref}: {constraints}. "
380
+ "Define a standalone schema containing the full restrictions."
381
+ )
382
+ # Keep references in the IR so recursive models stay recursive.
383
+ target = resolve_pointer(self.document, ref)
384
+ if isinstance(target, dict):
385
+ # Validate reference-only cycles without flattening the chain:
386
+ # flattening would hide constraints on intermediate references.
387
+ resolve_ref(self.document, cast(Document, target))
388
+ name = identifier(
389
+ ref.rsplit("/", 1)[-1].replace("~1", "/").replace("~0", "~"), upper=True
390
+ )
391
+ if name == "JsonValue" or (name in self.names and self.names[name] != ref):
392
+ raise ContractError(f"TypeScript type name collision: {name}")
393
+ self.names[name] = ref
394
+ self.pending[name] = target
395
+ return Reference(name)
396
+ if "const" in node:
397
+ return self.literal(node["const"])
398
+ if "enum" in node:
399
+ return Union(tuple(self.literal(value) for value in node["enum"]))
400
+ for keyword in ("oneOf", "anyOf", "allOf"):
401
+ if keyword in node:
402
+ members = tuple(self.parse(part) for part in node[keyword])
403
+ if keyword == "allOf":
404
+ combined: Type = Intersection(members)
405
+ else:
406
+ combined = Union(
407
+ members, node.get("discriminator", {}).get("propertyName")
408
+ )
409
+ siblings = {key: value for key, value in node.items() if key != keyword}
410
+ if any(
411
+ key in siblings
412
+ for key in (
413
+ "type",
414
+ "properties",
415
+ "additionalProperties",
416
+ "oneOf",
417
+ "anyOf",
418
+ "allOf",
419
+ )
420
+ ):
421
+ return Intersection((combined, self.parse(siblings)))
422
+ return combined
423
+ kind = node.get("type")
424
+ if isinstance(kind, list):
425
+ return Union(
426
+ tuple(
427
+ self.parse({**node, "type": part}) for part in cast(list[str], kind)
428
+ )
429
+ )
430
+ if kind in ("string", "boolean", "null"):
431
+ return Scalar(kind)
432
+ if kind in ("number", "integer"):
433
+ return Scalar(kind)
434
+ if kind == "array":
435
+ if "prefixItems" in node:
436
+ raise ContractError("Tuple schemas (prefixItems) are not supported")
437
+ return Array(self.parse(node.get("items", {})))
438
+ if kind == "object" or "properties" in node or "additionalProperties" in node:
439
+ properties: Document = dict(node.get("properties", {}))
440
+ required: list[str] = node.get("required", [])
441
+ extra = node.get("additionalProperties", not properties)
442
+ undeclared = set(required) - properties.keys()
443
+ if undeclared and node.get("additionalProperties") is False:
444
+ raise ContractError(
445
+ f"Required keys {sorted(undeclared)} are forbidden by "
446
+ "additionalProperties: false; declare them in properties."
447
+ )
448
+ # Required keys outside properties are governed by additionalProperties.
449
+ # Omission of that keyword leaves their values unrestricted, even when
450
+ # ordinary declared models retain the generator's default extra policy.
451
+ properties.update(
452
+ (name, node.get("additionalProperties", True))
453
+ for name in sorted(undeclared)
454
+ )
455
+ return Object(
456
+ tuple(
457
+ Field(
458
+ name,
459
+ self.parse(value),
460
+ name in required,
461
+ self.literal(value["default"])
462
+ if isinstance(value, dict)
463
+ and "default" in value
464
+ and isinstance(
465
+ value["default"], str | int | float | bool | None
466
+ )
467
+ else None,
468
+ )
469
+ for name, value in sorted(properties.items())
470
+ ),
471
+ self.parse(extra) if extra is not False else None,
472
+ node.get("additionalProperties") is False,
473
+ )
474
+ if set(node) <= {
475
+ "title",
476
+ "description",
477
+ "default",
478
+ "examples",
479
+ "deprecated",
480
+ "readOnly",
481
+ "writeOnly",
482
+ }:
483
+ return Scalar("JsonValue")
484
+ raise ContractError(f"Unsupported or ambiguous JSON Schema: {node!r}")
485
+
486
+ @staticmethod
487
+ def literal(value: Any) -> LiteralType:
488
+ if value is not None and not isinstance(value, str | int | float | bool):
489
+ raise ContractError("Object and array enum/const values are not supported")
490
+ return LiteralType(value)
491
+
492
+ def declarations(self) -> tuple[Declaration, ...]:
493
+ result: dict[str, Declaration] = {}
494
+ while todo := sorted(set(self.pending) - set(result)):
495
+ for name in todo:
496
+ result[name] = Declaration(name, self.parse(self.pending[name]))
497
+ return tuple(result[name] for name in sorted(result))
498
+
499
+
500
+ def _parameter_type(
501
+ type_: Type,
502
+ types: _Types,
503
+ seen: frozenset[str] = frozenset(),
504
+ *,
505
+ arrays: bool = True,
506
+ ) -> bool:
507
+ match type_:
508
+ case Scalar(name=name):
509
+ return name not in {"JsonValue", "never"}
510
+ case LiteralType():
511
+ return True
512
+ case Array(item=item):
513
+ return arrays and _parameter_type(item, types, seen, arrays=False)
514
+ case Union(members=members):
515
+ return all(
516
+ _parameter_type(member, types, seen, arrays=arrays)
517
+ for member in members
518
+ )
519
+ case Reference(name=name) if name not in seen:
520
+ return _parameter_type(
521
+ types.parse(types.pending[name]), types, seen | {name}, arrays=arrays
522
+ )
523
+ case _:
524
+ return False
@@ -0,0 +1,88 @@
1
+ import keyword
2
+ import re
3
+
4
+ from fastapi_streams.codegen.openapi import ContractError
5
+
6
+ _RESERVED = {
7
+ "break",
8
+ "case",
9
+ "catch",
10
+ "class",
11
+ "const",
12
+ "continue",
13
+ "debugger",
14
+ "default",
15
+ "delete",
16
+ "do",
17
+ "else",
18
+ "enum",
19
+ "export",
20
+ "extends",
21
+ "false",
22
+ "finally",
23
+ "for",
24
+ "function",
25
+ "if",
26
+ "import",
27
+ "in",
28
+ "instanceof",
29
+ "new",
30
+ "null",
31
+ "return",
32
+ "super",
33
+ "switch",
34
+ "this",
35
+ "throw",
36
+ "true",
37
+ "try",
38
+ "typeof",
39
+ "var",
40
+ "void",
41
+ "while",
42
+ "with",
43
+ "yield",
44
+ "let",
45
+ "static",
46
+ "implements",
47
+ "interface",
48
+ "package",
49
+ "private",
50
+ "protected",
51
+ "public",
52
+ "await",
53
+ "constructor",
54
+ "prototype",
55
+ "__proto__",
56
+ "then",
57
+ }
58
+
59
+
60
+ def words(value: str) -> list[str]:
61
+ return re.findall(r"[A-Za-z0-9]+", re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", value))
62
+
63
+
64
+ def identifier(value: str, *, upper: bool = False) -> str:
65
+ parts = words(value)
66
+ if not parts:
67
+ raise ContractError(f"Cannot make a TypeScript identifier from {value!r}")
68
+ name = "".join(part[:1].upper() + part[1:] for part in parts)
69
+ if not upper:
70
+ name = name[:1].lower() + name[1:]
71
+ if name[0].isdigit() or name in _RESERVED:
72
+ name = "_" + name
73
+ return name
74
+
75
+
76
+ def slug(value: str) -> str:
77
+ return "-".join(part.lower() for part in words(value)) or "streams"
78
+
79
+
80
+ def python_identifier(value: str) -> str:
81
+ name = "_".join(part.lower() for part in words(value))
82
+ if not name:
83
+ raise ContractError(f"Cannot make a Python identifier from {value!r}")
84
+ if name[0].isdigit():
85
+ name = "value_" + name
86
+ if keyword.iskeyword(name) or name.startswith("model_"):
87
+ name += "_"
88
+ return name