Skip to content

Commit 4cac5f5

Browse files
author
Anders Brams
committed
fix: freeform JSON objects normalize to mappings instead of dict[str, Any]
1 parent ef3359e commit 4cac5f5

10 files changed

Lines changed: 91 additions & 24 deletions

File tree

openapi_python/generator/model.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ class ListAnnotation:
2020
item: TypeAnnotation
2121

2222

23+
@dataclass(frozen=True)
24+
class MappingAnnotation:
25+
key: TypeAnnotation
26+
value: TypeAnnotation
27+
28+
2329
@dataclass(frozen=True)
2430
class LiteralAnnotation:
2531
values: tuple[object, ...]
@@ -44,6 +50,7 @@ class UnionAnnotation:
4450
AnyAnnotation
4551
| DictAnnotation
4652
| ListAnnotation
53+
| MappingAnnotation
4754
| LiteralAnnotation
4855
| NamedAnnotation
4956
| TupleAnnotation

openapi_python/generator/normalize.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
FieldDef,
1515
ListAnnotation,
1616
LiteralAnnotation,
17+
MappingAnnotation,
1718
NamedAnnotation,
1819
NormalizedSpec,
1920
OperationDef,
@@ -340,7 +341,7 @@ def _schema_map_to_type(
340341

341342
def _schema_freeform_object_to_type(schema: dict) -> TypeAnnotation:
342343
return _nullable(
343-
DictAnnotation(NamedAnnotation("str"), AnyAnnotation()),
344+
MappingAnnotation(NamedAnnotation("str"), AnyAnnotation()),
344345
bool(schema.get("nullable")),
345346
)
346347

openapi_python/generator/render.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
GeneratedArtifact,
1616
ListAnnotation,
1717
LiteralAnnotation,
18+
MappingAnnotation,
1819
NamedAnnotation,
1920
NormalizedSpec,
2021
OperationDef,
@@ -34,6 +35,8 @@ def _render_annotation(annotation: TypeAnnotation) -> str:
3435
return f"dict[{_render_annotation(key)}, {_render_annotation(value)}]"
3536
case ListAnnotation(item):
3637
return f"list[{_render_annotation(item)}]"
38+
case MappingAnnotation(key, value):
39+
return f"Mapping[{_render_annotation(key)}, {_render_annotation(value)}]"
3740
case LiteralAnnotation(values):
3841
return f"Literal[{', '.join(repr(value) for value in values)}]"
3942
case NamedAnnotation(name):
@@ -177,6 +180,10 @@ def _annotation_dependencies(annotation: TypeAnnotation, names: set[str]) -> set
177180
return _annotation_dependencies(key, names) | _annotation_dependencies(
178181
value, names
179182
)
183+
case MappingAnnotation(key, value):
184+
return _annotation_dependencies(key, names) | _annotation_dependencies(
185+
value, names
186+
)
180187
case ListAnnotation(item):
181188
return _annotation_dependencies(item, names)
182189
case NamedAnnotation(name):

openapi_python/generator/templates/types.py.j2

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from collections.abc import Mapping
34
from enum import Enum
45
{% if has_field_descriptions -%}
56
from importlib import import_module

tests/contract/freeform_objects/app.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ class TaskDto(BaseModel):
3535
executions: list[TaskExecutionDto] | None = None
3636

3737

38+
class DeploymentConfigRequest(BaseModel):
39+
config: dict
40+
franck_submission_id: str | None = None
41+
42+
3843
@app.get("/tasks", response_model=list[TaskDto])
3944
def list_tasks() -> list[TaskDto]:
4045
execution = TaskExecutionDto(
@@ -61,3 +66,8 @@ def list_tasks() -> list[TaskDto]:
6166
executions=[execution],
6267
)
6368
]
69+
70+
71+
@app.post("/deployment-configs", response_model=DeploymentConfigRequest)
72+
def create_deployment_config(body: DeploymentConfigRequest) -> DeploymentConfigRequest:
73+
return body

tests/contract/freeform_objects/generate.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,13 @@ def main() -> None:
4040
assert "class TaskExecutionDtoStacktraceVariant(TypedDict):" not in source
4141
assert "class TaskDtoMetaVariant(TypedDict):" not in source
4242
assert "class TaskDtoPayload(TypedDict):" not in source
43-
assert "log: list[dict[str, Any]]" in source
44-
assert "output: dict[str, Any] | None" in source
45-
assert "stacktrace: dict[str, Any] | None" in source
46-
assert "meta: dict[str, Any] | None" in source
47-
assert "payload: dict[str, Any]" in source
43+
assert "from collections.abc import Mapping" in source
44+
assert "log: list[Mapping[str, Any]]" in source
45+
assert "output: Mapping[str, Any] | None" in source
46+
assert "stacktrace: Mapping[str, Any] | None" in source
47+
assert "meta: Mapping[str, Any] | None" in source
48+
assert "payload: Mapping[str, Any]" in source
49+
assert "config: Mapping[str, Any]" in source
4850
assert "executions: NotRequired[list[TaskExecutionDto] | None]" in source
4951

5052
generated_types = importlib.import_module("generated.my_client.types")
Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from __future__ import annotations
22

3-
from typing import Any, assert_type
3+
from collections.abc import Mapping
4+
from typing import Any, TypedDict, assert_type
45

56
from generated.my_client import AsyncClient
6-
from generated.my_client.types import TaskDto, TaskExecutionDto
7+
from generated.my_client.types import DeploymentConfigRequest, TaskDto, TaskExecutionDto
78

89
client = AsyncClient(base_url="http://testserver")
910

@@ -12,12 +13,30 @@ async def main() -> None:
1213
result = await client.get("/tasks")()
1314
assert_type(result, list[TaskDto])
1415
task = result[0]
15-
assert_type(task["meta"], dict[str, Any] | None)
16-
assert_type(task["payload"], dict[str, Any])
16+
assert_type(task["meta"], Mapping[str, Any] | None)
17+
assert_type(task["payload"], Mapping[str, Any])
1718
assert_type(task["executions"], list[TaskExecutionDto] | None)
1819
execution = task["executions"][0] if task["executions"] is not None else None
1920
assert_type(execution, TaskExecutionDto | None)
2021
if execution is not None:
21-
assert_type(execution["log"], list[dict[str, Any]])
22-
assert_type(execution["output"], dict[str, Any] | None)
23-
assert_type(execution["stacktrace"], dict[str, Any] | None)
22+
assert_type(execution["log"], list[Mapping[str, Any]])
23+
assert_type(execution["output"], Mapping[str, Any] | None)
24+
assert_type(execution["stacktrace"], Mapping[str, Any] | None)
25+
26+
27+
class FortigateSDWANSpokeConfig(TypedDict):
28+
hostname: str
29+
serial_number: str
30+
31+
32+
async def create_deployment_config() -> None:
33+
config: FortigateSDWANSpokeConfig = {
34+
"hostname": "fw01",
35+
"serial_number": "FGT123",
36+
}
37+
body: DeploymentConfigRequest = {
38+
"config": config,
39+
"franck_submission_id": None,
40+
}
41+
created = await client.post("/deployment-configs")(body=body)
42+
assert_type(created, DeploymentConfigRequest)
Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,39 @@
11
from __future__ import annotations
22

3-
from typing import Any, assert_type
3+
from collections.abc import Mapping
4+
from typing import Any, TypedDict, assert_type
45

56
from generated.my_client import Client
6-
from generated.my_client.types import TaskDto, TaskExecutionDto
7+
from generated.my_client.types import DeploymentConfigRequest, TaskDto, TaskExecutionDto
78

89
client = Client(base_url="http://testserver")
910

1011
result = client.get("/tasks")()
1112
assert_type(result, list[TaskDto])
1213
task = result[0]
13-
assert_type(task["meta"], dict[str, Any] | None)
14-
assert_type(task["payload"], dict[str, Any])
14+
assert_type(task["meta"], Mapping[str, Any] | None)
15+
assert_type(task["payload"], Mapping[str, Any])
1516
assert_type(task["executions"], list[TaskExecutionDto] | None)
1617
execution = task["executions"][0] if task["executions"] is not None else None
1718
assert_type(execution, TaskExecutionDto | None)
1819
if execution is not None:
19-
assert_type(execution["log"], list[dict[str, Any]])
20-
assert_type(execution["output"], dict[str, Any] | None)
21-
assert_type(execution["stacktrace"], dict[str, Any] | None)
20+
assert_type(execution["log"], list[Mapping[str, Any]])
21+
assert_type(execution["output"], Mapping[str, Any] | None)
22+
assert_type(execution["stacktrace"], Mapping[str, Any] | None)
23+
24+
25+
class FortigateSDWANSpokeConfig(TypedDict):
26+
hostname: str
27+
serial_number: str
28+
29+
30+
config: FortigateSDWANSpokeConfig = {
31+
"hostname": "fw01",
32+
"serial_number": "FGT123",
33+
}
34+
body: DeploymentConfigRequest = {
35+
"config": config,
36+
"franck_submission_id": None,
37+
}
38+
created = client.post("/deployment-configs")(body=body)
39+
assert_type(created, DeploymentConfigRequest)

tests/contract/union/usage_async.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from collections.abc import Mapping
34
from typing import Any, assert_type
45

56
from generated.my_client import AsyncClient
@@ -20,8 +21,8 @@ async def use_async_client() -> None:
2021

2122
int_result = await async_client.get("/lookup/{value}")(params=int_params)
2223
assert_type(int_result, LookupResult)
23-
assert_type(int_result["value"], int | str | dict[str, Any])
24+
assert_type(int_result["value"], int | str | Mapping[str, Any])
2425

2526
str_result = await async_client.get("/lookup/{value}")(params=str_params)
2627
assert_type(str_result, LookupResult)
27-
assert_type(str_result["value"], int | str | dict[str, Any])
28+
assert_type(str_result["value"], int | str | Mapping[str, Any])

tests/contract/union/usage_sync.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from collections.abc import Mapping
34
from typing import Any, assert_type
45

56
from generated.my_client import Client
@@ -19,8 +20,8 @@
1920

2021
int_result = client.get("/lookup/{value}")(params=int_params)
2122
assert_type(int_result, LookupResult)
22-
assert_type(int_result["value"], int | str | dict[str, Any])
23+
assert_type(int_result["value"], int | str | Mapping[str, Any])
2324

2425
str_result = client.get("/lookup/{value}")(params=str_params)
2526
assert_type(str_result, LookupResult)
26-
assert_type(str_result["value"], int | str | dict[str, Any])
27+
assert_type(str_result["value"], int | str | Mapping[str, Any])

0 commit comments

Comments
 (0)