Skip to content

Commit 7cc8da3

Browse files
committed
fix: properly handling union aliases
1 parent f6c85e4 commit 7cc8da3

7 files changed

Lines changed: 180 additions & 12 deletions

File tree

README.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,31 @@
11
# openapi-python
22

3-
`openapi-python` generates strongly typed Python API clients from OpenAPI specs, with a developer-friendly and ergonomic string-literal-based interface strongly inspired by [openapi-typescript](https://openapi-ts.dev/).
3+
`openapi-python` generates typed Python API clients from OpenAPI specs, with a developer-friendly and ergonomic string-literal-based interface strongly inspired by [openapi-typescript](https://openapi-ts.dev/).
44

55
## Installation
66

7-
For generated clients that use the built-in `httpx` transport:
8-
97
```bash
8+
# For built-in httpx transport:
109
uv add openapi-python[httpx]
11-
```
12-
13-
For protocol-only generated clients where you provide the transport:
1410

15-
```bash
11+
# If you want to define your own HTTP transport (requests, asyncio, ...)
1612
uv add openapi-python
1713
```
1814

1915

20-
## CLI
16+
## Client generation
2117

2218
Generate a client from an OpenAPI spec in `openapi.json`:
2319

2420
```bash
25-
uv run openapi-python generate --spec ./openapi.json --out ./generated --package my_client
21+
# Types + HTTP client/transport
22+
uv run openapi-python generate --spec ./openapi.json --out ./generated
23+
24+
# Just types, use your own HTTP client/transport
25+
uv run openapi-python generate --spec ./openapi.json --out ./generated --transport-mode protocol-only
2626
```
2727

28-
## Programmatic API
28+
... or programatically:
2929

3030
```python
3131
from pathlib import Path

openapi_python/generator/normalize.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,9 @@ def _ensure_component(state: _TypeState, name: str) -> tuple[str, _TypeState]:
237237
type_name = _is_unique_type_name(state, _pascal(name))
238238

239239
state = _with_component_type_name(state, name, type_name)
240-
_, state = _schema_to_type(state, schema, type_name, component_name=name)
240+
annotation, state = _schema_to_type(state, schema, type_name, component_name=name)
241+
if not _is_registered_type_name(state, type_name):
242+
state = _with_alias(state, TypeAliasDef(name=type_name, annotation=annotation))
241243
return type_name, state
242244

243245

openapi_python/generator/render.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,36 @@ def _typed_dict_dependencies(defn: TypedDictDef, names: set[str]) -> set[str]:
109109
return dependencies
110110

111111

112+
def _alias_dependencies(defn: TypeAliasDef, names: set[str]) -> set[str]:
113+
return {
114+
name
115+
for name in names
116+
if name != defn.name and re.search(rf"\b{re.escape(name)}\b", defn.annotation)
117+
}
118+
119+
120+
def _order_aliases(defns: tuple[TypeAliasDef, ...]) -> list[TypeAliasDef]:
121+
by_name = {item.name: item for item in defns}
122+
names = set(by_name)
123+
ordered: list[TypeAliasDef] = []
124+
temporary: set[str] = set()
125+
permanent: set[str] = set()
126+
127+
def visit(name: str) -> None:
128+
if name in permanent or name in temporary:
129+
return
130+
temporary.add(name)
131+
for dependency in sorted(_alias_dependencies(by_name[name], names)):
132+
visit(dependency)
133+
temporary.remove(name)
134+
permanent.add(name)
135+
ordered.append(by_name[name])
136+
137+
for name in sorted(by_name):
138+
visit(name)
139+
return ordered
140+
141+
112142
def _order_typeddicts(defns: tuple[TypedDictDef, ...]) -> list[TypedDictDef]:
113143
by_name = {item.name: item for item in defns}
114144
names = set(by_name)
@@ -205,7 +235,7 @@ def _fallback_method_block(
205235
def _render_types(spec: NormalizedSpec) -> str:
206236
blocks = (
207237
[_format_enum(item) for item in spec.enums]
208-
+ [_format_alias(alias) for alias in spec.aliases]
238+
+ [_format_alias(alias) for alias in _order_aliases(spec.aliases)]
209239
+ [_format_typeddict(item) for item in _order_typeddicts(spec.typed_dicts)]
210240
)
211241
return _render_template(
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from pathlib import Path
5+
6+
from openapi_python.generator import GenerationRequest, generate_client
7+
8+
SPEC = {
9+
"openapi": "3.1.0",
10+
"info": {"title": "Component union alias", "version": "1.0.0"},
11+
"paths": {
12+
"/check": {
13+
"post": {
14+
"requestBody": {
15+
"required": True,
16+
"content": {
17+
"application/json": {
18+
"schema": {
19+
"$ref": "#/components/schemas/UserFGACheckRequest"
20+
}
21+
}
22+
},
23+
},
24+
"responses": {
25+
"200": {
26+
"content": {
27+
"application/json": {
28+
"schema": {
29+
"$ref": "#/components/schemas/UserFGACheckRequest"
30+
}
31+
}
32+
}
33+
}
34+
},
35+
}
36+
}
37+
},
38+
"components": {
39+
"schemas": {
40+
"FGAObjectType": {
41+
"anyOf": [
42+
{"type": "string", "enum": ["user", "sensor", "organization"]},
43+
{"type": "string"},
44+
]
45+
},
46+
"FGARelation": {
47+
"anyOf": [
48+
{"type": "string", "enum": ["owner", "member", "can_read"]},
49+
{"type": "string"},
50+
]
51+
},
52+
"UserFGACheckRequest": {
53+
"type": "object",
54+
"properties": {
55+
"relation": {"$ref": "#/components/schemas/FGARelation"},
56+
"object_type": {"$ref": "#/components/schemas/FGAObjectType"},
57+
"object_id": {"type": "string"},
58+
},
59+
"required": ["relation", "object_type", "object_id"],
60+
},
61+
}
62+
},
63+
}
64+
65+
66+
def main() -> None:
67+
generate_client(
68+
GenerationRequest(
69+
output_dir=Path(__file__).parent / "generated",
70+
spec_json=json.dumps(SPEC),
71+
overwrite=True,
72+
)
73+
)
74+
75+
76+
if __name__ == "__main__":
77+
main()
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"include": [
3+
"."
4+
],
5+
"exclude": [
6+
"generated"
7+
],
8+
"typeCheckingMode": "standard",
9+
"reportMissingImports": true,
10+
"reportUnusedVariable": true,
11+
"reportOptionalSubscript": true,
12+
"reportOptionalMemberAccess": true,
13+
"reportOptionalCall": true,
14+
"pythonVersion": "3.12"
15+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from __future__ import annotations
2+
3+
from typing import assert_type
4+
5+
from generated.my_client import AsyncClient
6+
from generated.my_client.types import FGAObjectType, FGARelation, UserFGACheckRequest
7+
8+
async_client = AsyncClient(base_url="http://testserver")
9+
10+
11+
async def use_async_client() -> None:
12+
body: UserFGACheckRequest = {
13+
"relation": "owner",
14+
"object_type": "sensor",
15+
"object_id": "sensor-1",
16+
}
17+
assert_type(body["relation"], FGARelation)
18+
assert_type(body["object_type"], FGAObjectType)
19+
20+
response = await async_client.post("/check")(body=body)
21+
assert_type(response, UserFGACheckRequest)
22+
assert_type(response["relation"], FGARelation)
23+
assert_type(response["object_type"], FGAObjectType)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from __future__ import annotations
2+
3+
from typing import assert_type
4+
5+
from generated.my_client import Client
6+
from generated.my_client.types import FGAObjectType, FGARelation, UserFGACheckRequest
7+
8+
client = Client(base_url="http://testserver")
9+
10+
body: UserFGACheckRequest = {
11+
"relation": "owner",
12+
"object_type": "sensor",
13+
"object_id": "sensor-1",
14+
}
15+
assert_type(body["relation"], FGARelation)
16+
assert_type(body["object_type"], FGAObjectType)
17+
18+
response = client.post("/check")(body=body)
19+
assert_type(response, UserFGACheckRequest)
20+
assert_type(response["relation"], FGARelation)
21+
assert_type(response["object_type"], FGAObjectType)

0 commit comments

Comments
 (0)