alt-stack-openapi-pydantic 1.6.2__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.
- alt_stack_openapi_pydantic/__init__.py +21 -0
- alt_stack_openapi_pydantic/cli.py +67 -0
- alt_stack_openapi_pydantic/dependencies.py +103 -0
- alt_stack_openapi_pydantic/emission.py +725 -0
- alt_stack_openapi_pydantic/lowering.py +116 -0
- alt_stack_openapi_pydantic/py.typed +1 -0
- alt_stack_openapi_pydantic/registry.py +161 -0
- alt_stack_openapi_pydantic/rendering.py +197 -0
- alt_stack_openapi_pydantic/routes.py +179 -0
- alt_stack_openapi_pydantic/schema_dedup.py +141 -0
- alt_stack_openapi_pydantic/to_python.py +12 -0
- alt_stack_openapi_pydantic/type_render.py +15 -0
- alt_stack_openapi_pydantic/types/__init__.py +5 -0
- alt_stack_openapi_pydantic/types/array.py +39 -0
- alt_stack_openapi_pydantic/types/boolean.py +14 -0
- alt_stack_openapi_pydantic/types/intersection.py +20 -0
- alt_stack_openapi_pydantic/types/number.py +39 -0
- alt_stack_openapi_pydantic/types/object.py +15 -0
- alt_stack_openapi_pydantic/types/string.py +64 -0
- alt_stack_openapi_pydantic/types/types.py +6 -0
- alt_stack_openapi_pydantic/types/union.py +37 -0
- alt_stack_openapi_pydantic/utils.py +32 -0
- alt_stack_openapi_pydantic-1.6.2.dist-info/METADATA +95 -0
- alt_stack_openapi_pydantic-1.6.2.dist-info/RECORD +28 -0
- alt_stack_openapi_pydantic-1.6.2.dist-info/WHEEL +5 -0
- alt_stack_openapi_pydantic-1.6.2.dist-info/entry_points.txt +2 -0
- alt_stack_openapi_pydantic-1.6.2.dist-info/licenses/LICENSE +21 -0
- alt_stack_openapi_pydantic-1.6.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .dependencies import topological_sort_schemas
|
|
7
|
+
from .lowering import LoweringContext, NamedSchema, lower_named_schema
|
|
8
|
+
from .rendering import (
|
|
9
|
+
RenderContext,
|
|
10
|
+
decode_component_ref,
|
|
11
|
+
extra_field_annotation,
|
|
12
|
+
is_object_model_schema,
|
|
13
|
+
root_model_annotation,
|
|
14
|
+
schema_to_type_expr,
|
|
15
|
+
)
|
|
16
|
+
from .routes import (
|
|
17
|
+
RouteInfo,
|
|
18
|
+
build_route_path_name,
|
|
19
|
+
build_route_schema_name,
|
|
20
|
+
generate_route_schema_names,
|
|
21
|
+
parse_openapi_paths,
|
|
22
|
+
)
|
|
23
|
+
from .schema_dedup import (
|
|
24
|
+
NamedSchema as DedupNamedSchema,
|
|
25
|
+
)
|
|
26
|
+
from .schema_dedup import (
|
|
27
|
+
create_schema_registry,
|
|
28
|
+
find_common_schemas,
|
|
29
|
+
get_schema_fingerprint,
|
|
30
|
+
pre_register_schema,
|
|
31
|
+
register_schema,
|
|
32
|
+
)
|
|
33
|
+
from .types import AnySchema
|
|
34
|
+
|
|
35
|
+
_VALID_IDENTIFIER_CHARS = set("_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class EmissionState:
|
|
40
|
+
render_context: RenderContext = field(default_factory=RenderContext)
|
|
41
|
+
rebuild_names: list[str] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def openapi_to_pydantic_code(
|
|
45
|
+
openapi: dict[str, Any],
|
|
46
|
+
custom_import_lines: list[str] | None = None,
|
|
47
|
+
options: dict[str, Any] | None = None,
|
|
48
|
+
) -> str:
|
|
49
|
+
include_routes = bool(options.get("include_routes") if options else False)
|
|
50
|
+
components = openapi.get("components")
|
|
51
|
+
schemas = components.get("schemas") if isinstance(components, dict) else {}
|
|
52
|
+
if not isinstance(schemas, dict):
|
|
53
|
+
schemas = {}
|
|
54
|
+
|
|
55
|
+
lowering_context = LoweringContext(set(schemas.keys()))
|
|
56
|
+
lowered_components: list[NamedSchema] = []
|
|
57
|
+
for name in topological_sort_schemas(schemas):
|
|
58
|
+
schema = schemas.get(name)
|
|
59
|
+
if schema is None:
|
|
60
|
+
continue
|
|
61
|
+
lowered_components.extend(lower_named_schema(lowering_context, name, schema))
|
|
62
|
+
|
|
63
|
+
lines = _build_module_preamble(custom_import_lines)
|
|
64
|
+
state = EmissionState()
|
|
65
|
+
registry = create_schema_registry()
|
|
66
|
+
|
|
67
|
+
for named_schema in lowered_components:
|
|
68
|
+
lines.extend(_emit_named_schema(named_schema.name, named_schema.schema, state))
|
|
69
|
+
pre_register_schema(
|
|
70
|
+
registry,
|
|
71
|
+
named_schema.name,
|
|
72
|
+
get_schema_fingerprint(named_schema.schema),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if include_routes:
|
|
76
|
+
routes = parse_openapi_paths(openapi)
|
|
77
|
+
if routes:
|
|
78
|
+
route_lines, schema_name_to_canonical = _emit_route_schemas(
|
|
79
|
+
routes,
|
|
80
|
+
lowering_context,
|
|
81
|
+
registry,
|
|
82
|
+
state,
|
|
83
|
+
)
|
|
84
|
+
if route_lines:
|
|
85
|
+
lines.append("# Route Schemas")
|
|
86
|
+
lines.extend(route_lines)
|
|
87
|
+
lines.append("")
|
|
88
|
+
|
|
89
|
+
lines.extend(_emit_model_rebuilds(state.rebuild_names))
|
|
90
|
+
if route_lines:
|
|
91
|
+
lines.append("")
|
|
92
|
+
lines.extend(_generate_request_response_objects(routes, schema_name_to_canonical))
|
|
93
|
+
return _finalize_module(lines)
|
|
94
|
+
|
|
95
|
+
lines.extend(_emit_model_rebuilds(state.rebuild_names))
|
|
96
|
+
return _finalize_module(lines)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_ALL_OF_HELPER = [
|
|
100
|
+
"def all_of(*types: Any) -> Any:",
|
|
101
|
+
" adapters = [TypeAdapter(t) for t in types]",
|
|
102
|
+
"",
|
|
103
|
+
" def validate(value: Any) -> Any:",
|
|
104
|
+
" for adapter in adapters:",
|
|
105
|
+
" adapter.validate_python(value)",
|
|
106
|
+
" return value",
|
|
107
|
+
"",
|
|
108
|
+
" return Annotated[Any, BeforeValidator(validate)]",
|
|
109
|
+
"",
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _finalize_module(lines: list[str]) -> str:
|
|
114
|
+
"""Inline the ``all_of`` intersection helper when the module uses it.
|
|
115
|
+
|
|
116
|
+
Generated modules depend only on pydantic and ``alt_stack_http_client_httpx`` at runtime,
|
|
117
|
+
not on the generator package.
|
|
118
|
+
"""
|
|
119
|
+
anchor = "_omit_not_null = BeforeValidator(_reject_explicit_none)"
|
|
120
|
+
if any("all_of(" in line for line in lines) and anchor in lines:
|
|
121
|
+
index = lines.index(anchor) + 1
|
|
122
|
+
lines[index:index] = ["", *_ALL_OF_HELPER[:-1]]
|
|
123
|
+
return "\n".join(lines)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _build_module_preamble(custom_import_lines: list[str] | None) -> list[str]:
|
|
127
|
+
lines = [
|
|
128
|
+
"# This file was automatically generated from OpenAPI schema",
|
|
129
|
+
"# Do not manually edit this file",
|
|
130
|
+
"from __future__ import annotations",
|
|
131
|
+
"",
|
|
132
|
+
"from typing import Any, Annotated, Final, Literal, Optional, TypedDict, Union",
|
|
133
|
+
"from typing import cast, overload",
|
|
134
|
+
"from datetime import date, datetime",
|
|
135
|
+
"from uuid import UUID",
|
|
136
|
+
"",
|
|
137
|
+
"from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, RootModel",
|
|
138
|
+
"from pydantic import TypeAdapter",
|
|
139
|
+
"from pydantic import AnyUrl, EmailStr",
|
|
140
|
+
"import alt_stack_http_client_httpx as _client",
|
|
141
|
+
"",
|
|
142
|
+
"def _reject_explicit_none(value: Any) -> Any:",
|
|
143
|
+
" if value is None:",
|
|
144
|
+
' raise ValueError("Field may be omitted but may not be null")',
|
|
145
|
+
" return value",
|
|
146
|
+
"",
|
|
147
|
+
"_omit_not_null = BeforeValidator(_reject_explicit_none)",
|
|
148
|
+
"",
|
|
149
|
+
]
|
|
150
|
+
if custom_import_lines:
|
|
151
|
+
lines.extend(custom_import_lines)
|
|
152
|
+
lines.append("")
|
|
153
|
+
return lines
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _emit_named_schema(name: str, schema: AnySchema, state: EmissionState) -> list[str]:
|
|
157
|
+
if is_object_model_schema(schema) and not _has_root_model_allof_base(schema, state):
|
|
158
|
+
return _emit_object_model(name, schema, state)
|
|
159
|
+
return _emit_root_model(name, schema, state)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _has_root_model_allof_base(schema: AnySchema, state: EmissionState) -> bool:
|
|
163
|
+
if not isinstance(schema, dict):
|
|
164
|
+
return False
|
|
165
|
+
all_of = schema.get("allOf")
|
|
166
|
+
if not isinstance(all_of, list):
|
|
167
|
+
return False
|
|
168
|
+
return any(
|
|
169
|
+
isinstance(part, dict)
|
|
170
|
+
and isinstance(part.get("$ref"), str)
|
|
171
|
+
and decode_component_ref(part["$ref"]) in state.render_context.root_model_names
|
|
172
|
+
for part in all_of
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _emit_object_model(name: str, schema: AnySchema, state: EmissionState) -> list[str]:
|
|
177
|
+
all_of = schema.get("allOf") if isinstance(schema, dict) else None
|
|
178
|
+
if isinstance(all_of, list):
|
|
179
|
+
base_classes, merged_schema = _merge_allof_object_schema(all_of)
|
|
180
|
+
model_lines = _build_model_lines(name, merged_schema, base_classes=base_classes or None)
|
|
181
|
+
model_lines.insert(1, f" __openapi_allof__ = {all_of!r}")
|
|
182
|
+
else:
|
|
183
|
+
model_lines = _build_model_lines(name, schema)
|
|
184
|
+
|
|
185
|
+
state.rebuild_names.append(name)
|
|
186
|
+
return [*model_lines, ""]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _emit_root_model(name: str, schema: AnySchema, state: EmissionState) -> list[str]:
|
|
190
|
+
state.render_context.root_model_names.add(name)
|
|
191
|
+
type_expr = schema_to_type_expr(schema, context=state.render_context)
|
|
192
|
+
state.rebuild_names.append(name)
|
|
193
|
+
return [
|
|
194
|
+
f"class {name}({root_model_annotation(type_expr)}):",
|
|
195
|
+
" pass",
|
|
196
|
+
"",
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _merge_allof_object_schema(all_of: list[Any]) -> tuple[list[str], dict[str, Any]]:
|
|
201
|
+
base_classes: list[str] = []
|
|
202
|
+
inline_properties: dict[str, Any] = {}
|
|
203
|
+
inline_required: list[str] = []
|
|
204
|
+
additional_properties: Any = None
|
|
205
|
+
|
|
206
|
+
for part in all_of:
|
|
207
|
+
if not isinstance(part, dict):
|
|
208
|
+
continue
|
|
209
|
+
if isinstance(part.get("$ref"), str):
|
|
210
|
+
base_classes.append(decode_component_ref(part["$ref"]))
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
if part.get("type") == "object" or "properties" in part:
|
|
214
|
+
props = part.get("properties")
|
|
215
|
+
if isinstance(props, dict):
|
|
216
|
+
inline_properties.update(props)
|
|
217
|
+
|
|
218
|
+
required = part.get("required")
|
|
219
|
+
if isinstance(required, list):
|
|
220
|
+
for item in required:
|
|
221
|
+
if item not in inline_required:
|
|
222
|
+
inline_required.append(item)
|
|
223
|
+
|
|
224
|
+
additional = part.get("additionalProperties")
|
|
225
|
+
if additional is False:
|
|
226
|
+
additional_properties = False
|
|
227
|
+
elif isinstance(additional, dict):
|
|
228
|
+
additional_properties = additional
|
|
229
|
+
|
|
230
|
+
merged_schema: dict[str, Any] = {
|
|
231
|
+
"type": "object",
|
|
232
|
+
"properties": inline_properties,
|
|
233
|
+
"required": inline_required,
|
|
234
|
+
}
|
|
235
|
+
if additional_properties is not None:
|
|
236
|
+
merged_schema["additionalProperties"] = additional_properties
|
|
237
|
+
return base_classes, merged_schema
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _build_model_lines(
|
|
241
|
+
name: str,
|
|
242
|
+
schema: AnySchema,
|
|
243
|
+
*,
|
|
244
|
+
base_classes: list[str] | None = None,
|
|
245
|
+
) -> list[str]:
|
|
246
|
+
properties = schema.get("properties") if isinstance(schema, dict) else None
|
|
247
|
+
required = set(schema.get("required", [])) if isinstance(schema, dict) else set()
|
|
248
|
+
additional = schema.get("additionalProperties") if isinstance(schema, dict) else None
|
|
249
|
+
|
|
250
|
+
bases = base_classes or ["BaseModel"]
|
|
251
|
+
lines = [f"class {name}({', '.join(bases)}):"]
|
|
252
|
+
|
|
253
|
+
alias_used = False
|
|
254
|
+
field_lines: list[str] = []
|
|
255
|
+
used_names: set[str] = set()
|
|
256
|
+
|
|
257
|
+
if isinstance(properties, dict):
|
|
258
|
+
for prop_name, prop_schema in properties.items():
|
|
259
|
+
attr_name = _dedupe_name(_sanitize_identifier(prop_name), used_names)
|
|
260
|
+
alias = prop_name if attr_name != prop_name else None
|
|
261
|
+
if alias:
|
|
262
|
+
alias_used = True
|
|
263
|
+
|
|
264
|
+
type_expr = schema_to_type_expr(prop_schema)
|
|
265
|
+
is_required = prop_name in required
|
|
266
|
+
if is_required:
|
|
267
|
+
default_expr = f" = Field(alias={alias!r})" if alias else ""
|
|
268
|
+
else:
|
|
269
|
+
if not isinstance(prop_schema, dict) or prop_schema.get("nullable") is not True:
|
|
270
|
+
type_expr = f"Annotated[Optional[{type_expr}], _omit_not_null]"
|
|
271
|
+
default_expr = f" = Field(default=None, alias={alias!r})" if alias else " = None"
|
|
272
|
+
|
|
273
|
+
field_lines.append(f" {attr_name}: {type_expr}{default_expr}")
|
|
274
|
+
|
|
275
|
+
config_args: list[str] = []
|
|
276
|
+
extra_line: str | None = None
|
|
277
|
+
if alias_used:
|
|
278
|
+
config_args.append("populate_by_name=True")
|
|
279
|
+
|
|
280
|
+
if additional is False:
|
|
281
|
+
config_args.append("extra='forbid'")
|
|
282
|
+
elif additional is True or additional is None:
|
|
283
|
+
config_args.append("extra='allow'")
|
|
284
|
+
elif isinstance(additional, dict):
|
|
285
|
+
config_args.append("extra='allow'")
|
|
286
|
+
extra_type = schema_to_type_expr(additional)
|
|
287
|
+
extra_line = (
|
|
288
|
+
f" __pydantic_extra__: {extra_field_annotation(extra_type)} = Field(init=False)"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if config_args:
|
|
292
|
+
lines.append(f" model_config = ConfigDict({', '.join(config_args)})")
|
|
293
|
+
if extra_line:
|
|
294
|
+
lines.append(extra_line)
|
|
295
|
+
|
|
296
|
+
if field_lines:
|
|
297
|
+
lines.extend(field_lines)
|
|
298
|
+
elif len(lines) == 1:
|
|
299
|
+
lines.append(" pass")
|
|
300
|
+
|
|
301
|
+
return lines
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _emit_model_rebuilds(names: list[str]) -> list[str]:
|
|
305
|
+
if not names:
|
|
306
|
+
return []
|
|
307
|
+
return [*(f"{name}.model_rebuild()" for name in names), ""]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _emit_route_schemas(
|
|
311
|
+
routes: list[RouteInfo],
|
|
312
|
+
lowering_context: LoweringContext,
|
|
313
|
+
registry: Any,
|
|
314
|
+
state: EmissionState,
|
|
315
|
+
) -> tuple[list[str], dict[str, str]]:
|
|
316
|
+
lines: list[str] = []
|
|
317
|
+
route_named_schemas: list[NamedSchema] = []
|
|
318
|
+
|
|
319
|
+
schema_name_to_canonical: dict[str, str] = {}
|
|
320
|
+
|
|
321
|
+
def register_named_schema_batch(named_schemas: list[NamedSchema]) -> None:
|
|
322
|
+
local_canonical: dict[str, str] = {}
|
|
323
|
+
for named_schema in named_schemas:
|
|
324
|
+
canonical_schema = _rewrite_schema_refs(named_schema.schema, local_canonical)
|
|
325
|
+
result = register_schema(registry, named_schema.name, canonical_schema)
|
|
326
|
+
schema_name_to_canonical[named_schema.name] = result.canonical_name
|
|
327
|
+
local_canonical[named_schema.name] = result.canonical_name
|
|
328
|
+
if result.is_new:
|
|
329
|
+
route_named_schemas.append(
|
|
330
|
+
NamedSchema(name=named_schema.name, schema=canonical_schema)
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
for common in find_common_schemas(_collect_route_schemas(routes), 2):
|
|
334
|
+
if common.fingerprint in registry.fingerprint_to_name:
|
|
335
|
+
continue
|
|
336
|
+
register_named_schema_batch(
|
|
337
|
+
lower_named_schema(lowering_context, common.name, common.schema)
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
def register_route_schema(name: str, schema: AnySchema) -> None:
|
|
341
|
+
if _route_schema_ref_target(schema):
|
|
342
|
+
return
|
|
343
|
+
register_named_schema_batch(lower_named_schema(lowering_context, name, schema))
|
|
344
|
+
|
|
345
|
+
for route in routes:
|
|
346
|
+
names = generate_route_schema_names(route)
|
|
347
|
+
path_params = [p for p in route.parameters if p.location == "path"]
|
|
348
|
+
query_params = [p for p in route.parameters if p.location == "query"]
|
|
349
|
+
header_params = [p for p in route.parameters if p.location == "header"]
|
|
350
|
+
|
|
351
|
+
if names.params_schema_name and path_params:
|
|
352
|
+
register_route_schema(
|
|
353
|
+
names.params_schema_name,
|
|
354
|
+
_build_openapi_object_schema(
|
|
355
|
+
[{"name": p.name, "schema": p.schema, "required": True} for p in path_params]
|
|
356
|
+
),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
if names.query_schema_name and query_params:
|
|
360
|
+
register_route_schema(
|
|
361
|
+
names.query_schema_name,
|
|
362
|
+
_build_openapi_object_schema(
|
|
363
|
+
[
|
|
364
|
+
{"name": p.name, "schema": p.schema, "required": p.required}
|
|
365
|
+
for p in query_params
|
|
366
|
+
]
|
|
367
|
+
),
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
if names.headers_schema_name and header_params:
|
|
371
|
+
register_route_schema(
|
|
372
|
+
names.headers_schema_name,
|
|
373
|
+
_build_openapi_object_schema(
|
|
374
|
+
[
|
|
375
|
+
{"name": p.name, "schema": p.schema, "required": p.required}
|
|
376
|
+
for p in header_params
|
|
377
|
+
]
|
|
378
|
+
),
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
if names.body_schema_name and route.request_body is not None:
|
|
382
|
+
register_route_schema(names.body_schema_name, route.request_body)
|
|
383
|
+
|
|
384
|
+
for status_code, response_schema in route.responses.items():
|
|
385
|
+
if not response_schema:
|
|
386
|
+
continue
|
|
387
|
+
suffix = "Response" if status_code.startswith("2") else "ErrorResponse"
|
|
388
|
+
register_route_schema(
|
|
389
|
+
build_route_schema_name(route.path, route.method, f"{status_code}{suffix}"),
|
|
390
|
+
response_schema,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
for named_schema in route_named_schemas:
|
|
394
|
+
lines.extend(_emit_named_schema(named_schema.name, named_schema.schema, state))
|
|
395
|
+
|
|
396
|
+
return lines, schema_name_to_canonical
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _collect_route_schemas(routes: list[RouteInfo]) -> list[DedupNamedSchema]:
|
|
400
|
+
collected: list[DedupNamedSchema] = []
|
|
401
|
+
for route in routes:
|
|
402
|
+
for status_code, response_schema in route.responses.items():
|
|
403
|
+
if not response_schema:
|
|
404
|
+
continue
|
|
405
|
+
suffix = "Response" if status_code.startswith("2") else "ErrorResponse"
|
|
406
|
+
collected.append(
|
|
407
|
+
{
|
|
408
|
+
"name": build_route_schema_name(
|
|
409
|
+
route.path,
|
|
410
|
+
route.method,
|
|
411
|
+
f"{status_code}{suffix}",
|
|
412
|
+
),
|
|
413
|
+
"schema": response_schema,
|
|
414
|
+
}
|
|
415
|
+
)
|
|
416
|
+
return collected
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
RouteMap = dict[str, dict[str, dict[str, str]]]
|
|
420
|
+
"""path -> METHOD -> request part or status code -> model class name."""
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _generate_request_response_objects(
|
|
424
|
+
routes: list[RouteInfo],
|
|
425
|
+
schema_name_to_canonical: dict[str, str],
|
|
426
|
+
) -> list[str]:
|
|
427
|
+
request_paths: RouteMap = {}
|
|
428
|
+
response_paths: RouteMap = {}
|
|
429
|
+
|
|
430
|
+
def resolve_schema_name(name: str, schema: AnySchema) -> str:
|
|
431
|
+
ref_target = _route_schema_ref_target(schema)
|
|
432
|
+
if ref_target:
|
|
433
|
+
return ref_target
|
|
434
|
+
return schema_name_to_canonical.get(name, name)
|
|
435
|
+
|
|
436
|
+
for route in routes:
|
|
437
|
+
names = generate_route_schema_names(route)
|
|
438
|
+
path_params = [p for p in route.parameters if p.location == "path"]
|
|
439
|
+
query_params = [p for p in route.parameters if p.location == "query"]
|
|
440
|
+
header_params = [p for p in route.parameters if p.location == "header"]
|
|
441
|
+
|
|
442
|
+
request_parts: dict[str, str] = {}
|
|
443
|
+
|
|
444
|
+
if names.params_schema_name and path_params:
|
|
445
|
+
schema = _build_openapi_object_schema(
|
|
446
|
+
[{"name": p.name, "schema": p.schema, "required": True} for p in path_params]
|
|
447
|
+
)
|
|
448
|
+
request_parts["params"] = resolve_schema_name(names.params_schema_name, schema)
|
|
449
|
+
|
|
450
|
+
if names.query_schema_name and query_params:
|
|
451
|
+
schema = _build_openapi_object_schema(
|
|
452
|
+
[{"name": p.name, "schema": p.schema, "required": p.required} for p in query_params]
|
|
453
|
+
)
|
|
454
|
+
request_parts["query"] = resolve_schema_name(names.query_schema_name, schema)
|
|
455
|
+
|
|
456
|
+
if names.headers_schema_name and header_params:
|
|
457
|
+
schema = _build_openapi_object_schema(
|
|
458
|
+
[
|
|
459
|
+
{"name": p.name, "schema": p.schema, "required": p.required}
|
|
460
|
+
for p in header_params
|
|
461
|
+
]
|
|
462
|
+
)
|
|
463
|
+
request_parts["headers"] = resolve_schema_name(names.headers_schema_name, schema)
|
|
464
|
+
|
|
465
|
+
if names.body_schema_name and route.request_body is not None:
|
|
466
|
+
request_parts["body"] = resolve_schema_name(names.body_schema_name, route.request_body)
|
|
467
|
+
|
|
468
|
+
# Bare methods keep an empty entry so `Request[path][METHOD]` is a closed,
|
|
469
|
+
# statically known shape, matching the TypeScript `as const` maps.
|
|
470
|
+
request_paths.setdefault(route.path, {})[route.method] = request_parts
|
|
471
|
+
|
|
472
|
+
response_statuses: dict[str, str] = {}
|
|
473
|
+
for status_code, response_schema in route.responses.items():
|
|
474
|
+
if not response_schema:
|
|
475
|
+
continue
|
|
476
|
+
suffix = "Response" if status_code.startswith("2") else "ErrorResponse"
|
|
477
|
+
schema_name = build_route_schema_name(
|
|
478
|
+
route.path,
|
|
479
|
+
route.method,
|
|
480
|
+
f"{status_code}{suffix}",
|
|
481
|
+
)
|
|
482
|
+
response_statuses[status_code] = resolve_schema_name(schema_name, response_schema)
|
|
483
|
+
response_paths.setdefault(route.path, {})[route.method] = response_statuses
|
|
484
|
+
|
|
485
|
+
used_type_names: set[str] = set()
|
|
486
|
+
lines = _emit_route_map("Request", request_paths, used_type_names)
|
|
487
|
+
lines.append("")
|
|
488
|
+
lines.extend(_emit_route_map("Response", response_paths, used_type_names))
|
|
489
|
+
lines.append("")
|
|
490
|
+
lines.extend(_emit_typed_client(request_paths, response_paths))
|
|
491
|
+
return lines
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _emit_route_map(name: str, paths: RouteMap, used_type_names: set[str]) -> list[str]:
|
|
495
|
+
"""Emit a closed ``TypedDict`` shape for a route map plus its runtime dictionary.
|
|
496
|
+
|
|
497
|
+
Every level of the map gets its own ``TypedDict`` so a type checker resolves
|
|
498
|
+
``Map[path][METHOD][key]`` to the exact ``type[Model]`` and rejects keys that are
|
|
499
|
+
not in the OpenAPI document. The runtime value stays a plain nested ``dict``.
|
|
500
|
+
"""
|
|
501
|
+
lines: list[str] = []
|
|
502
|
+
path_type_names: dict[str, str] = {}
|
|
503
|
+
|
|
504
|
+
for path, methods in paths.items():
|
|
505
|
+
method_type_names: dict[str, str] = {}
|
|
506
|
+
for method, leaves in methods.items():
|
|
507
|
+
leaf_type_name = _dedupe_name(
|
|
508
|
+
f"_{build_route_schema_name(path, method, name)}", used_type_names
|
|
509
|
+
)
|
|
510
|
+
method_type_names[method] = leaf_type_name
|
|
511
|
+
lines.extend(
|
|
512
|
+
_emit_typed_dict(
|
|
513
|
+
leaf_type_name,
|
|
514
|
+
{key: f"type[{model_name}]" for key, model_name in leaves.items()},
|
|
515
|
+
)
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
path_type_name = _dedupe_name(
|
|
519
|
+
f"_{build_route_path_name(path)}{name}Methods", used_type_names
|
|
520
|
+
)
|
|
521
|
+
path_type_names[path] = path_type_name
|
|
522
|
+
lines.extend(_emit_typed_dict(path_type_name, method_type_names))
|
|
523
|
+
|
|
524
|
+
map_type_name = _dedupe_name(f"_{name}Map", used_type_names)
|
|
525
|
+
lines.extend(_emit_typed_dict(map_type_name, path_type_names))
|
|
526
|
+
lines.append("")
|
|
527
|
+
|
|
528
|
+
lines.append(f"{name}: Final[{map_type_name}] = {{")
|
|
529
|
+
for path, methods in paths.items():
|
|
530
|
+
lines.append(f" {path!r}: {{")
|
|
531
|
+
for method, leaves in methods.items():
|
|
532
|
+
if not leaves:
|
|
533
|
+
lines.append(f" {method!r}: {{}},")
|
|
534
|
+
continue
|
|
535
|
+
lines.append(f" {method!r}: {{")
|
|
536
|
+
for key, model_name in leaves.items():
|
|
537
|
+
lines.append(f" {key!r}: {model_name},")
|
|
538
|
+
lines.append(" },")
|
|
539
|
+
lines.append(" },")
|
|
540
|
+
lines.append("}")
|
|
541
|
+
return lines
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
_CLIENT_METHOD_ORDER = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS")
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _emit_typed_client(request_paths: RouteMap, response_paths: RouteMap) -> list[str]:
|
|
548
|
+
"""Emit ``{Method}{Path}Result`` unions and an ``ApiClient`` with typed route methods.
|
|
549
|
+
|
|
550
|
+
Python cannot derive a call signature from the ``TypedDict`` maps the way TypeScript
|
|
551
|
+
maps over ``typeof Request``, so each route gets an explicit ``Literal`` path overload
|
|
552
|
+
carrying its exact request models and result union. Methods with a single route are
|
|
553
|
+
emitted as a plain typed signature because a lone ``@overload`` is invalid.
|
|
554
|
+
"""
|
|
555
|
+
lines: list[str] = ["# Typed Client"]
|
|
556
|
+
routes_by_method: dict[str, list[tuple[str, dict[str, str], str]]] = {}
|
|
557
|
+
|
|
558
|
+
for path, methods in request_paths.items():
|
|
559
|
+
for method, parts in methods.items():
|
|
560
|
+
result_name = build_route_schema_name(path, method, "Result")
|
|
561
|
+
statuses = response_paths.get(path, {}).get(method, {})
|
|
562
|
+
lines.extend(_emit_result_alias(result_name, statuses))
|
|
563
|
+
routes_by_method.setdefault(method, []).append((path, parts, result_name))
|
|
564
|
+
|
|
565
|
+
lines.append("")
|
|
566
|
+
lines.append("# Typed route methods; construct HttpxApiClient(url, request_map=Request,")
|
|
567
|
+
lines.append("# response_map=Response) or ApiClient(url, transport=..., request_map=..., ...).")
|
|
568
|
+
lines.append("class ApiClient(_client.BaseApiClient):")
|
|
569
|
+
for index, method in enumerate(m for m in _CLIENT_METHOD_ORDER if m in routes_by_method):
|
|
570
|
+
if index:
|
|
571
|
+
lines.append("")
|
|
572
|
+
lines.extend(_emit_client_method(method, routes_by_method[method]))
|
|
573
|
+
|
|
574
|
+
lines.append("")
|
|
575
|
+
lines.append("class HttpxApiClient(ApiClient, _client.HttpxApiClient):")
|
|
576
|
+
lines.append(" pass")
|
|
577
|
+
return lines
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _emit_result_alias(result_name: str, statuses: dict[str, str]) -> list[str]:
|
|
581
|
+
members: list[str] = []
|
|
582
|
+
for status_code, model_name in statuses.items():
|
|
583
|
+
result_class = "ApiSuccess" if status_code.startswith("2") else "ApiFailure"
|
|
584
|
+
members.append(f"_client.{result_class}[Literal[{status_code!r}], {model_name}]")
|
|
585
|
+
if not any(status_code.startswith("2") for status_code in statuses):
|
|
586
|
+
# No documented 2xx body (for example 204): success carries the raw payload.
|
|
587
|
+
members.append("_client.ApiSuccess[str, Any]")
|
|
588
|
+
members.append("_client.ApiUnexpectedError")
|
|
589
|
+
return [f"{result_name} = Union[", *(f" {member}," for member in members), "]"]
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _emit_client_method(method: str, routes: list[tuple[str, dict[str, str], str]]) -> list[str]:
|
|
593
|
+
method_name = method.lower()
|
|
594
|
+
lines: list[str] = []
|
|
595
|
+
|
|
596
|
+
if len(routes) == 1:
|
|
597
|
+
path, parts, result_name = routes[0]
|
|
598
|
+
lines.extend(_emit_route_signature(method_name, path, parts, result_name, overload=False))
|
|
599
|
+
forwarded = ", ".join(f"{key}={key}" for key in parts if key != "headers")
|
|
600
|
+
call_args = ", ".join(filter(None, [f"{method!r}", "path", forwarded, "options=options"]))
|
|
601
|
+
lines.append(" return cast(")
|
|
602
|
+
lines.append(f" {result_name},")
|
|
603
|
+
lines.append(f" await self.request({call_args}),")
|
|
604
|
+
lines.append(" )")
|
|
605
|
+
return lines
|
|
606
|
+
|
|
607
|
+
for path, parts, result_name in routes:
|
|
608
|
+
lines.extend(_emit_route_signature(method_name, path, parts, result_name, overload=True))
|
|
609
|
+
lines.append("")
|
|
610
|
+
lines.append(f" async def {method_name}(")
|
|
611
|
+
lines.append(" self,")
|
|
612
|
+
lines.append(" path: str,")
|
|
613
|
+
lines.append(" *,")
|
|
614
|
+
lines.append(" params: Any = None,")
|
|
615
|
+
lines.append(" query: Any = None,")
|
|
616
|
+
lines.append(" body: Any = None,")
|
|
617
|
+
lines.append(" options: _client.RequestOptions | None = None,")
|
|
618
|
+
lines.append(" ) -> Any:")
|
|
619
|
+
lines.append(" return await self.request(")
|
|
620
|
+
lines.append(
|
|
621
|
+
f" {method!r}, path, params=params, query=query, body=body, options=options"
|
|
622
|
+
)
|
|
623
|
+
lines.append(" )")
|
|
624
|
+
return lines
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _emit_route_signature(
|
|
628
|
+
method_name: str,
|
|
629
|
+
path: str,
|
|
630
|
+
parts: dict[str, str],
|
|
631
|
+
result_name: str,
|
|
632
|
+
*,
|
|
633
|
+
overload: bool,
|
|
634
|
+
) -> list[str]:
|
|
635
|
+
lines: list[str] = []
|
|
636
|
+
if overload:
|
|
637
|
+
lines.append(" @overload")
|
|
638
|
+
lines.append(f" async def {method_name}(")
|
|
639
|
+
lines.append(" self,")
|
|
640
|
+
lines.append(f" path: Literal[{path!r}],")
|
|
641
|
+
lines.append(" *,")
|
|
642
|
+
if "params" in parts:
|
|
643
|
+
lines.append(f" params: {parts['params']},")
|
|
644
|
+
if "query" in parts:
|
|
645
|
+
lines.append(f" query: {parts['query']} | None = None,")
|
|
646
|
+
if "body" in parts:
|
|
647
|
+
lines.append(f" body: {parts['body']},")
|
|
648
|
+
lines.append(" options: _client.RequestOptions | None = None,")
|
|
649
|
+
lines.append(f" ) -> {result_name}:{' ...' if overload else ''}")
|
|
650
|
+
return lines
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _emit_typed_dict(type_name: str, fields: dict[str, str]) -> list[str]:
|
|
654
|
+
if not fields:
|
|
655
|
+
return [f"{type_name} = TypedDict({type_name!r}, {{}})"]
|
|
656
|
+
return [
|
|
657
|
+
f"{type_name} = TypedDict({type_name!r}, {{",
|
|
658
|
+
*(f" {key!r}: {value}," for key, value in fields.items()),
|
|
659
|
+
"})",
|
|
660
|
+
]
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _build_openapi_object_schema(params: list[dict[str, Any]]) -> dict[str, Any]:
|
|
664
|
+
return {
|
|
665
|
+
"type": "object",
|
|
666
|
+
"properties": {param["name"]: param["schema"] for param in params},
|
|
667
|
+
"required": [param["name"] for param in params if param.get("required")],
|
|
668
|
+
"additionalProperties": False,
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _route_schema_ref_target(schema: AnySchema) -> str | None:
|
|
673
|
+
if not isinstance(schema, dict):
|
|
674
|
+
return None
|
|
675
|
+
ref = schema.get("$ref")
|
|
676
|
+
if not isinstance(ref, str) or schema.get("nullable") is True:
|
|
677
|
+
return None
|
|
678
|
+
if set(schema.keys()) != {"$ref"}:
|
|
679
|
+
return None
|
|
680
|
+
return decode_component_ref(ref)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _rewrite_schema_refs(schema: AnySchema, aliases: dict[str, str]) -> AnySchema:
|
|
684
|
+
if not isinstance(schema, dict):
|
|
685
|
+
return schema
|
|
686
|
+
|
|
687
|
+
rewritten = dict(schema)
|
|
688
|
+
ref = rewritten.get("$ref")
|
|
689
|
+
if isinstance(ref, str):
|
|
690
|
+
name = decode_component_ref(ref)
|
|
691
|
+
canonical = aliases.get(name)
|
|
692
|
+
if canonical and canonical != name:
|
|
693
|
+
rewritten["$ref"] = f"#/components/schemas/{canonical}"
|
|
694
|
+
|
|
695
|
+
for key, value in list(rewritten.items()):
|
|
696
|
+
if isinstance(value, dict):
|
|
697
|
+
rewritten[key] = _rewrite_schema_refs(value, aliases)
|
|
698
|
+
elif isinstance(value, list):
|
|
699
|
+
rewritten[key] = [
|
|
700
|
+
_rewrite_schema_refs(item, aliases) if isinstance(item, dict) else item
|
|
701
|
+
for item in value
|
|
702
|
+
]
|
|
703
|
+
|
|
704
|
+
return rewritten
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _sanitize_identifier(name: str) -> str:
|
|
708
|
+
result = "".join(ch if ch in _VALID_IDENTIFIER_CHARS else "_" for ch in name)
|
|
709
|
+
if result and result[0].isupper() and result[1:].islower():
|
|
710
|
+
result = result[0].lower() + result[1:]
|
|
711
|
+
if not result or result[0].isdigit():
|
|
712
|
+
result = f"_{result}"
|
|
713
|
+
return f"{result}_" if result in {"class", "from", "global", "pass"} else result
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _dedupe_name(name: str, used: set[str]) -> str:
|
|
717
|
+
if name not in used:
|
|
718
|
+
used.add(name)
|
|
719
|
+
return name
|
|
720
|
+
index = 2
|
|
721
|
+
while f"{name}_{index}" in used:
|
|
722
|
+
index += 1
|
|
723
|
+
deduped = f"{name}_{index}"
|
|
724
|
+
used.add(deduped)
|
|
725
|
+
return deduped
|