Skip to content

Commit 5ce5d1c

Browse files
committed
refactor: normalize produces pure models instead of half-rendered annotations
1 parent 29662e3 commit 5ce5d1c

5 files changed

Lines changed: 184 additions & 72 deletions

File tree

openapi_python/generator/model.py

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,60 @@
11
from __future__ import annotations
22

33
from dataclasses import dataclass, field
4+
from typing import TypeAlias
5+
6+
7+
@dataclass(frozen=True)
8+
class AnyAnnotation:
9+
pass
10+
11+
12+
@dataclass(frozen=True)
13+
class DictAnnotation:
14+
key: TypeAnnotation
15+
value: TypeAnnotation
16+
17+
18+
@dataclass(frozen=True)
19+
class ListAnnotation:
20+
item: TypeAnnotation
21+
22+
23+
@dataclass(frozen=True)
24+
class LiteralAnnotation:
25+
values: tuple[object, ...]
26+
27+
28+
@dataclass(frozen=True)
29+
class NamedAnnotation:
30+
name: str
31+
32+
33+
@dataclass(frozen=True)
34+
class TupleAnnotation:
35+
items: tuple[TypeAnnotation, ...]
36+
37+
38+
@dataclass(frozen=True)
39+
class UnionAnnotation:
40+
items: tuple[TypeAnnotation, ...]
41+
42+
43+
TypeAnnotation: TypeAlias = (
44+
AnyAnnotation
45+
| DictAnnotation
46+
| ListAnnotation
47+
| LiteralAnnotation
48+
| NamedAnnotation
49+
| TupleAnnotation
50+
| UnionAnnotation
51+
)
452

553

654
@dataclass(frozen=True)
755
class FieldDef:
856
name: str
9-
annotation: str
57+
annotation: TypeAnnotation
1058
required: bool
1159

1260

@@ -19,7 +67,7 @@ class TypedDictDef:
1967
@dataclass(frozen=True)
2068
class TypeAliasDef:
2169
name: str
22-
annotation: str
70+
annotation: TypeAnnotation
2371

2472

2573
@dataclass(frozen=True)
@@ -34,15 +82,15 @@ class OperationDef:
3482
route_literal: str
3583
symbol: str
3684
protocol_name: str
37-
params_type: str
85+
params_type: TypeAnnotation
3886
params_required: bool
39-
query_type: str
87+
query_type: TypeAnnotation
4088
query_required: bool
41-
headers_type: str
89+
headers_type: TypeAnnotation
4290
headers_required: bool
43-
body_type: str | None
91+
body_type: TypeAnnotation | None
4492
body_required: bool
45-
response_type: str
93+
response_type: TypeAnnotation
4694

4795

4896
@dataclass(frozen=True)

openapi_python/generator/normalize.py

Lines changed: 69 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,20 @@
88
from ..utils import safe_get
99
from .diagnostics import invalid_spec
1010
from .model import (
11+
AnyAnnotation,
12+
DictAnnotation,
1113
EnumDef,
1214
FieldDef,
15+
ListAnnotation,
16+
LiteralAnnotation,
17+
NamedAnnotation,
1318
NormalizedSpec,
1419
OperationDef,
20+
TupleAnnotation,
1521
TypeAliasDef,
22+
TypeAnnotation,
1623
TypedDictDef,
24+
UnionAnnotation,
1725
)
1826

1927
_METHODS = ("get", "post", "put", "patch", "delete", "head", "options")
@@ -115,7 +123,7 @@ def _is_used_type_name(state: _TypeState, name: str) -> bool:
115123
)
116124

117125

118-
def _is_unique_type_name(state: _TypeState, name: str) -> str:
126+
def _unique_type_name(state: _TypeState, name: str) -> str:
119127
if not _is_used_type_name(state, name):
120128
return name
121129

@@ -214,50 +222,56 @@ def _without_processing(state: _TypeState, name: str) -> _TypeState:
214222
)
215223

216224

217-
def _union(variants: Iterable[str]) -> str:
218-
unique: list[str] = []
225+
def _union(variants: Iterable[TypeAnnotation]) -> TypeAnnotation:
226+
unique: list[TypeAnnotation] = []
219227
for item in variants:
220228
if item not in unique:
221229
unique.append(item)
222-
return " | ".join(unique) if unique else "Any"
230+
if not unique:
231+
return AnyAnnotation()
232+
if len(unique) == 1:
233+
return unique[0]
234+
return UnionAnnotation(tuple(unique))
223235

224236

225-
def _ensure_component(state: _TypeState, name: str) -> tuple[str, _TypeState]:
237+
def _ensure_component(
238+
state: _TypeState, name: str
239+
) -> tuple[TypeAnnotation, _TypeState]:
226240
"""
227241
Ensures that a component schema is registered as a type.
228242
"""
229243
existing = state.component_type_names.get(name)
230244
if existing is not None:
231-
return existing, state
245+
return NamedAnnotation(existing), state
232246

233247
schema = safe_get(state.components, "schemas", name, type=dict)
234248
if schema is None:
235249
raise invalid_spec("Unresolved component schema reference", name)
236250

237-
type_name = _is_unique_type_name(state, _pascal(name))
251+
type_name = _unique_type_name(state, _pascal(name))
238252

239253
state = _with_component_type_name(state, name, type_name)
240254
annotation, state = _schema_to_type(state, schema, type_name, component_name=name)
241255
if not _is_registered_type_name(state, type_name):
242256
state = _with_alias(state, TypeAliasDef(name=type_name, annotation=annotation))
243-
return type_name, state
257+
return NamedAnnotation(type_name), state
244258

245259

246-
def _nullable(annotation: str, nullable: bool) -> str:
247-
return f"{annotation} | None" if nullable else annotation
260+
def _nullable(annotation: TypeAnnotation, nullable: bool) -> TypeAnnotation:
261+
return _union((annotation, NamedAnnotation("None"))) if nullable else annotation
248262

249263

250264
def _schema_enum_to_type(
251265
state: _TypeState,
252266
schema: dict,
253267
hint: str,
254268
component_name: str | None,
255-
) -> tuple[str, _TypeState]:
269+
) -> tuple[TypeAnnotation, _TypeState]:
256270
values = schema["enum"]
257271
if component_name is not None:
258272
# Component-level enums are rendered as actual reusable Enum classes
259273
enum = EnumDef(name=hint, values=tuple(values))
260-
return enum.name, _with_enum(state, enum)
274+
return NamedAnnotation(enum.name), _with_enum(state, enum)
261275

262276
# Inline enums are just rendered as literals
263277
title = str(schema.get("title") or "")
@@ -267,17 +281,16 @@ def _schema_enum_to_type(
267281
if signature is not None:
268282
existing = state.aliases_by_signature.get(signature)
269283
if existing:
270-
return existing, state
284+
return NamedAnnotation(existing), state
271285

272-
literal_values = ", ".join(values_list)
273-
alias = TypeAliasDef(name=alias_name, annotation=f"Literal[{literal_values}]")
274-
return alias_name, _with_alias(state, alias, signature)
286+
alias = TypeAliasDef(name=alias_name, annotation=LiteralAnnotation(tuple(values)))
287+
return NamedAnnotation(alias_name), _with_alias(state, alias, signature)
275288

276289

277290
def _schema_union_to_type(
278291
state: _TypeState, schemas: list, hint: str
279-
) -> tuple[str, _TypeState]:
280-
variants: list[str] = []
292+
) -> tuple[TypeAnnotation, _TypeState]:
293+
variants: list[TypeAnnotation] = []
281294
for item in schemas:
282295
item_type, state = _schema_to_type(state, item, f"{hint}Variant")
283296
variants.append(item_type)
@@ -286,11 +299,11 @@ def _schema_union_to_type(
286299

287300
def _schema_type_list_to_type(
288301
state: _TypeState, schema_types: list, hint: str
289-
) -> tuple[str, _TypeState]:
290-
mapped: list[str] = []
302+
) -> tuple[TypeAnnotation, _TypeState]:
303+
mapped: list[TypeAnnotation] = []
291304
for schema_type in schema_types:
292305
if schema_type == "null":
293-
mapped.append("None")
306+
mapped.append(NamedAnnotation("None"))
294307
continue
295308
item_type, state = _schema_to_type(state, {"type": schema_type}, hint)
296309
mapped.append(item_type)
@@ -299,39 +312,41 @@ def _schema_type_list_to_type(
299312

300313
def _schema_array_to_type(
301314
state: _TypeState, schema: dict, hint: str
302-
) -> tuple[str, _TypeState]:
315+
) -> tuple[TypeAnnotation, _TypeState]:
303316
nullable = bool(schema.get("nullable"))
304317
prefix_items = safe_get(schema, "prefixItems", type=list)
305318
if prefix_items is not None:
306-
item_types: list[str] = []
319+
item_types: list[TypeAnnotation] = []
307320
for item in prefix_items:
308321
item_type, state = _schema_to_type(state, item, f"{hint}Item")
309322
item_types.append(item_type)
310-
return _nullable(f"tuple[{', '.join(item_types)}]", nullable), state
323+
return _nullable(TupleAnnotation(tuple(item_types)), nullable), state
311324

312325
item_schema = safe_get(schema, "items", type=dict) or {}
313326
item_type, state = _schema_to_type(state, item_schema, f"{hint}Item")
314-
return _nullable(f"list[{item_type}]", nullable), state
327+
return _nullable(ListAnnotation(item_type), nullable), state
315328

316329

317330
def _schema_map_to_type(
318331
state: _TypeState, schema: dict, hint: str
319-
) -> tuple[str, _TypeState]:
332+
) -> tuple[TypeAnnotation, _TypeState]:
320333
nullable = bool(schema.get("nullable"))
321334
additional_properties = schema["additionalProperties"]
322335
value_type, state = _schema_to_type(state, additional_properties, f"{hint}Value")
323-
return _nullable(f"dict[str, {value_type}]", nullable), state
336+
return _nullable(
337+
DictAnnotation(NamedAnnotation("str"), value_type), nullable
338+
), state
324339

325340

326341
def _schema_object_to_type(
327342
state: _TypeState, schema: dict, hint: str
328-
) -> tuple[str, _TypeState]:
343+
) -> tuple[TypeAnnotation, _TypeState]:
329344
nullable = bool(schema.get("nullable"))
330345
name = _type_name_from_hint(hint)
331346
if name in state.processing:
332-
return name, state
347+
return NamedAnnotation(name), state
333348
if name in state.typed_dicts:
334-
return name, state
349+
return NamedAnnotation(name), state
335350

336351
state = _with_processing(state, name)
337352
props = safe_get(schema, "properties", type=dict) or {}
@@ -353,7 +368,7 @@ def _schema_object_to_type(
353368

354369
state = _without_processing(state, name)
355370
state = _with_typeddict(state, TypedDictDef(name=name, fields=tuple(fields)))
356-
return _nullable(name, nullable), state
371+
return _nullable(NamedAnnotation(name), nullable), state
357372

358373

359374
def _schema_to_type(
@@ -362,30 +377,30 @@ def _schema_to_type(
362377
hint: str,
363378
*,
364379
component_name: str | None = None,
365-
) -> tuple[str, _TypeState]:
380+
) -> tuple[TypeAnnotation, _TypeState]:
366381
"""
367-
Takes a JSON schema object and returns the corresponding Python
368-
type annotation along with an updated state containing any new
382+
Takes a JSON schema object and returns the corresponding Python type
383+
annotation model along with an updated state containing any new
369384
type definitions.
370385
"""
371386
if not schema:
372-
return "Any", state
387+
return AnyAnnotation(), state
373388

374389
schema_type = schema.get("type")
375390
nullable = bool(schema.get("nullable"))
376391
if schema_type == "null":
377-
return "None", state
392+
return NamedAnnotation("None"), state
378393

379394
ref = schema.get("$ref")
380395
if isinstance(ref, str) and ref.startswith("#/components/schemas/"):
381396
component = ref.rsplit("/", 1)[-1]
382397
return _ensure_component(state, component)
383398

384399
if "const" in schema:
385-
return f"Literal[{schema['const']!r}]", state
400+
return LiteralAnnotation((schema["const"],)), state
386401

387402
if schema_type == "string" and schema.get("format") == "binary":
388-
return "bytes", state
403+
return NamedAnnotation("bytes"), state
389404

390405
if "enum" in schema and isinstance(schema["enum"], list):
391406
return _schema_enum_to_type(state, schema, hint, component_name)
@@ -414,10 +429,15 @@ def _schema_to_type(
414429
return _schema_object_to_type(state, schema, hint)
415430

416431
base = _PRIMITIVES.get(str(schema_type), "Any")
417-
return _nullable(base, nullable), state
432+
annotation: TypeAnnotation = (
433+
AnyAnnotation() if base == "Any" else NamedAnnotation(base)
434+
)
435+
return _nullable(annotation, nullable), state
418436

419437

420-
def _schema_type(state: _TypeState, schema: dict, hint: str) -> tuple[str, _TypeState]:
438+
def _schema_type(
439+
state: _TypeState, schema: dict, hint: str
440+
) -> tuple[TypeAnnotation, _TypeState]:
421441
return _schema_to_type(state, schema or {}, hint)
422442

423443

@@ -507,10 +527,10 @@ def _bucket_type(
507527
state: _TypeState,
508528
bucket: _ParameterBucket,
509529
hint: str,
510-
default: str = "dict[str, Any]",
511-
) -> tuple[str, _TypeState]:
530+
default: TypeAnnotation | None = None,
531+
) -> tuple[TypeAnnotation, _TypeState]:
512532
if not bucket.props:
513-
return default, state
533+
return default or DictAnnotation(NamedAnnotation("str"), AnyAnnotation()), state
514534
return _schema_type(
515535
state,
516536
{
@@ -526,11 +546,11 @@ def _request_body_type(
526546
state: _TypeState,
527547
operation: dict,
528548
hint: str,
529-
) -> tuple[str | None, bool, _TypeState]:
549+
) -> tuple[TypeAnnotation | None, bool, _TypeState]:
530550
"""
531551
Determines the type of the request body for an operation, if any.
532552
Returns a tuple of (body_type, required, updated_state).
533-
The body_type is a string representing the Python type annotation for the request body.
553+
The body_type is the Python type annotation model for the request body.
534554
The required flag indicates whether the request body is required.
535555
The updated_state is the new _TypeState after processing the request body schema.
536556
"""
@@ -549,9 +569,9 @@ def _request_body_type(
549569

550570
def _response_type(
551571
state: _TypeState, operation: dict, hint: str
552-
) -> tuple[str, _TypeState]:
572+
) -> tuple[TypeAnnotation, _TypeState]:
553573
responses = safe_get(operation, "responses", type=dict) or {}
554-
response_types: list[str] = []
574+
response_types: list[TypeAnnotation] = []
555575

556576
for code in sorted(responses.keys()):
557577
if not code.startswith("2"):
@@ -563,12 +583,12 @@ def _response_type(
563583
)
564584
if schema is not None:
565585
if not schema:
566-
response_types.append("None")
586+
response_types.append(NamedAnnotation("None"))
567587
else:
568588
response_type, state = _schema_type(state, schema, hint)
569589
response_types.append(response_type)
570590
else:
571-
response_types.append("None")
591+
response_types.append(NamedAnnotation("None"))
572592
return _union(response_types), state
573593

574594

0 commit comments

Comments
 (0)