Skip to content

Commit a9b4f54

Browse files
authored
Fix msgspec type hint generation for oneOf/anyOf with null type (#2629)
* Add support for oneOf containing null type in msgspec Struct generation * Refactor annotated property to improve ClassVar handling and simplify type hint generation * Add MSGSPEC_LEGACY_BLACK_SKIP decorator to oneOf null type tests * Replace MSGSPEC_LEGACY_BLACK_SKIP decorator with LEGACY_BLACK_SKIP in oneOf null type tests
1 parent 1111918 commit a9b4f54

5 files changed

Lines changed: 158 additions & 11 deletions

File tree

src/datamodel_code_generator/model/msgspec.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,19 @@ def get_neither_required_nor_nullable_type(type_: str, use_union_operator: bool)
180180
return f"{UNION_PREFIX}{type_}{UNION_DELIMITER}{UNSET_TYPE}]"
181181

182182

183+
@lru_cache
184+
def _add_unset_type(type_: str, use_union_operator: bool) -> str: # noqa: FBT001
185+
"""Add UnsetType to a type hint without removing None."""
186+
if use_union_operator:
187+
return f"{type_}{UNION_OPERATOR_DELIMITER}{UNSET_TYPE}"
188+
if type_.startswith(UNION_PREFIX):
189+
return f"{type_[:-1]}{UNION_DELIMITER}{UNSET_TYPE}]"
190+
if type_.startswith(OPTIONAL_PREFIX): # pragma: no cover
191+
inner_type = type_[len(OPTIONAL_PREFIX) : -1]
192+
return f"{UNION_PREFIX}{inner_type}{UNION_DELIMITER}{NONE}{UNION_DELIMITER}{UNSET_TYPE}]"
193+
return f"{UNION_PREFIX}{type_}{UNION_DELIMITER}{UNSET_TYPE}]"
194+
195+
183196
@import_extender
184197
class DataModelField(DataModelFieldBase):
185198
"""Field implementation for msgspec Struct models."""
@@ -281,7 +294,9 @@ def __str__(self) -> str:
281294
def type_hint(self) -> str:
282295
"""Return the type hint, using UnsetType for non-required non-nullable fields."""
283296
type_hint = super().type_hint
284-
if self._not_required and not self.nullable and not self.data_type.is_optional:
297+
if self._not_required and not self.nullable:
298+
if self.data_type.is_optional:
299+
return _add_unset_type(type_hint, self.data_type.use_union_operator)
285300
return get_neither_required_nor_nullable_type(type_hint, self.data_type.use_union_operator)
286301
return type_hint
287302

@@ -316,7 +331,7 @@ def _get_meta_string(self) -> str | None:
316331
return f"Meta({', '.join(meta_arguments)})" if meta_arguments else None
317332

318333
@property
319-
def annotated(self) -> str | None:
334+
def annotated(self) -> str | None: # noqa: PLR0911
320335
"""Get Annotated type hint with Meta constraints.
321336
322337
For ClassVar fields (discriminator tag_field), ClassVar is required
@@ -325,24 +340,26 @@ def annotated(self) -> str | None:
325340
if self.extras.get("is_classvar"):
326341
meta = self._get_meta_string()
327342
if self.use_annotated and meta:
328-
annotated_type = f"Annotated[{self.type_hint}, {meta}]"
329-
return f"ClassVar[{annotated_type}]"
343+
return f"ClassVar[Annotated[{self.type_hint}, {meta}]]"
330344
return f"ClassVar[{self.type_hint}]"
331345

332346
if not self.use_annotated: # pragma: no cover
333347
return None
334348

335349
meta = self._get_meta_string()
336-
337350
if not meta:
338351
return None
339352

340-
if not self.required:
341-
type_hint = self.data_type.type_hint
342-
annotated_type = f"Annotated[{type_hint}, {meta}]"
343-
return get_neither_required_nor_nullable_type(annotated_type, self.data_type.use_union_operator)
344-
345-
return f"Annotated[{self.type_hint}, {meta}]"
353+
if self.required:
354+
return f"Annotated[{self.type_hint}, {meta}]"
355+
356+
type_hint = self.data_type.type_hint
357+
annotated_type = f"Annotated[{type_hint}, {meta}]"
358+
if self.nullable: # pragma: no cover
359+
return annotated_type
360+
if self.data_type.is_optional: # pragma: no cover
361+
return _add_unset_type(annotated_type, self.data_type.use_union_operator)
362+
return get_neither_required_nor_nullable_type(annotated_type, self.data_type.use_union_operator)
346363

347364
@property
348365
def needs_annotated_import(self) -> bool:
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# generated by datamodel-codegen:
2+
# filename: msgspec_oneof_with_null.yaml
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Annotated, Union
8+
9+
from msgspec import UNSET, Meta, Struct, UnsetType
10+
from typing_extensions import TypeAlias
11+
12+
OptionalOneofWithNullAndConstraint: TypeAlias = Annotated[str, Meta(max_length=100)]
13+
14+
15+
class Model(Struct):
16+
required_field: str
17+
optional_oneof_with_null: Union[str, None, UnsetType] = UNSET
18+
optional_anyof_with_null: Union[str, None, UnsetType] = UNSET
19+
optional_field_not_nullable: Union[str, UnsetType] = UNSET
20+
optional_oneof_with_null_and_constraint: Union[
21+
OptionalOneofWithNullAndConstraint, None, UnsetType
22+
] = UNSET
23+
optional_nullable_field: Union[str, UnsetType] = UNSET
24+
optional_nullable_with_constraint: Union[
25+
Annotated[str, Meta(max_length=50)], UnsetType
26+
] = UNSET
27+
optional_nullable_with_min_length: Union[
28+
Annotated[str, Meta(min_length=5)], UnsetType
29+
] = UNSET
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# generated by datamodel-codegen:
2+
# filename: msgspec_oneof_with_null.yaml
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Annotated
8+
9+
from msgspec import UNSET, Meta, Struct, UnsetType
10+
from typing_extensions import TypeAlias
11+
12+
OptionalOneofWithNullAndConstraint: TypeAlias = Annotated[str, Meta(max_length=100)]
13+
14+
15+
class Model(Struct):
16+
required_field: str
17+
optional_oneof_with_null: str | None | UnsetType = UNSET
18+
optional_anyof_with_null: str | None | UnsetType = UNSET
19+
optional_field_not_nullable: str | UnsetType = UNSET
20+
optional_oneof_with_null_and_constraint: (
21+
OptionalOneofWithNullAndConstraint | None | UnsetType
22+
) = UNSET
23+
optional_nullable_field: str | UnsetType = UNSET
24+
optional_nullable_with_constraint: (
25+
Annotated[str, Meta(max_length=50)] | UnsetType
26+
) = UNSET
27+
optional_nullable_with_min_length: (
28+
Annotated[str, Meta(min_length=5)] | UnsetType
29+
) = UNSET
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
openapi: "3.0.0"
2+
info:
3+
version: 1.0.0
4+
title: Test oneOf with null
5+
components:
6+
schemas:
7+
Model:
8+
type: object
9+
properties:
10+
required_field:
11+
type: string
12+
optional_oneof_with_null:
13+
oneOf:
14+
- type: string
15+
- type: "null"
16+
optional_anyof_with_null:
17+
anyOf:
18+
- type: string
19+
- type: "null"
20+
optional_field_not_nullable:
21+
type: string
22+
optional_oneof_with_null_and_constraint:
23+
oneOf:
24+
- type: string
25+
maxLength: 100
26+
- type: "null"
27+
optional_nullable_field:
28+
type: string
29+
nullable: true
30+
optional_nullable_with_constraint:
31+
type: string
32+
nullable: true
33+
maxLength: 50
34+
optional_nullable_with_min_length:
35+
type: string
36+
nullable: true
37+
minLength: 5
38+
required:
39+
- required_field

tests/main/openapi/test_main_openapi.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2391,6 +2391,39 @@ def test_main_openapi_msgspec_anyof(min_version: str, output_file: Path) -> None
23912391
)
23922392

23932393

2394+
@LEGACY_BLACK_SKIP
2395+
def test_main_openapi_msgspec_oneof_with_null(output_file: Path) -> None:
2396+
"""Test msgspec Struct generation with oneOf containing null type."""
2397+
run_main_and_assert(
2398+
input_path=OPEN_API_DATA_PATH / "msgspec_oneof_with_null.yaml",
2399+
output_path=output_file,
2400+
input_file_type="openapi",
2401+
assert_func=assert_file_content,
2402+
expected_file="msgspec_oneof_with_null.py",
2403+
extra_args=[
2404+
"--output-model-type",
2405+
"msgspec.Struct",
2406+
],
2407+
)
2408+
2409+
2410+
@LEGACY_BLACK_SKIP
2411+
def test_main_openapi_msgspec_oneof_with_null_union_operator(output_file: Path) -> None:
2412+
"""Test msgspec Struct generation with oneOf containing null type using union operator."""
2413+
run_main_and_assert(
2414+
input_path=OPEN_API_DATA_PATH / "msgspec_oneof_with_null.yaml",
2415+
output_path=output_file,
2416+
input_file_type="openapi",
2417+
assert_func=assert_file_content,
2418+
expected_file="msgspec_oneof_with_null_union_operator.py",
2419+
extra_args=[
2420+
"--output-model-type",
2421+
"msgspec.Struct",
2422+
"--use-union-operator",
2423+
],
2424+
)
2425+
2426+
23942427
def test_main_openapi_referenced_default(output_file: Path) -> None:
23952428
"""Test OpenAPI generation with referenced default values."""
23962429
run_main_and_assert(

0 commit comments

Comments
 (0)