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,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)
|