Skip to content

Commit 67a8dd5

Browse files
committed
Handle duplicate discriminator values
1 parent a425913 commit 67a8dd5

6 files changed

Lines changed: 700 additions & 56 deletions

File tree

src/datamodel_code_generator/parser/base.py

Lines changed: 169 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181
from datamodel_code_generator.parser.generation import GenerationIndex, GenerationStore, set_model_base_classes
8282
from datamodel_code_generator.parser.schema_version import SchemaFeaturesT
8383
from datamodel_code_generator.reference import ModelResolver, ModelType, Reference
84-
from datamodel_code_generator.types import ANY, DataType, DataTypeManager
84+
from datamodel_code_generator.types import ANY, NONE, DataType, DataTypeManager
8585
from datamodel_code_generator.util import camel_to_snake
8686

8787
if TYPE_CHECKING:
@@ -1100,6 +1100,145 @@ def _get_discriminator_field_value(discriminator_field: DataModelFieldBase) -> D
11001100
return None
11011101

11021102

1103+
def _find_discriminator_value(fields: Iterable[DataModelFieldBase], field_name: str) -> DiscriminatorValue | None:
1104+
for field in fields:
1105+
if (
1106+
field_name in {field.original_name, field.name}
1107+
and (value := _get_discriminator_field_value(field)) is not None
1108+
):
1109+
return value
1110+
return None
1111+
1112+
1113+
def _get_discriminator_values(
1114+
discriminator_model: DataModel,
1115+
field_name: str,
1116+
mapping: dict[str, str],
1117+
*,
1118+
require_literal: bool = False,
1119+
) -> list[DiscriminatorValue]:
1120+
if (value := _find_discriminator_value(discriminator_model.fields, field_name)) is not None:
1121+
return [value]
1122+
1123+
# Reuse models are created as empty subclasses with a "/reuse" path suffix.
1124+
# Nested choices cannot be updated later, so also accept inherited literals.
1125+
if (require_literal or discriminator_model.path.endswith("/reuse")) and (
1126+
value := _find_discriminator_value(discriminator_model.iter_all_fields(), field_name)
1127+
) is not None:
1128+
return [value]
1129+
if require_literal:
1130+
return []
1131+
1132+
discriminator_values: list[DiscriminatorValue] = []
1133+
if mapping:
1134+
_check_discriminator_mapping_paths(discriminator_model, mapping, discriminator_values)
1135+
if not discriminator_values:
1136+
for base_class in discriminator_model.base_classes:
1137+
if base_class.reference:
1138+
_check_discriminator_mapping_paths(base_class.reference, mapping, discriminator_values)
1139+
1140+
return discriminator_values or [discriminator_model.path.split("/")[-1]]
1141+
1142+
1143+
def _remove_discriminator(field: DataModelFieldBase) -> None:
1144+
field.extras.pop("discriminator", None)
1145+
field.data_type.discriminator = None
1146+
1147+
1148+
def _is_discriminator_container(data_type: DataType) -> bool:
1149+
return (
1150+
data_type.is_dict
1151+
or data_type.is_list
1152+
or data_type.is_set
1153+
or data_type.is_frozen_set
1154+
or data_type.is_mapping
1155+
or data_type.is_sequence
1156+
or data_type.is_tuple
1157+
)
1158+
1159+
1160+
def _iter_discriminator_data_types(
1161+
data_types: Iterable[DataType],
1162+
pydantic_v2_root_model_type: type[DataModel] | None,
1163+
active_union_models: set[int] | None = None,
1164+
*,
1165+
can_update_discriminator: bool = True,
1166+
discriminator_owner: int | None = None,
1167+
) -> Iterator[tuple[DataType, bool, int]]:
1168+
for data_type in data_types:
1169+
if data_type.is_union and not _is_discriminator_container(data_type):
1170+
yield from _iter_discriminator_data_types(
1171+
data_type.data_types,
1172+
pydantic_v2_root_model_type,
1173+
active_union_models,
1174+
can_update_discriminator=False,
1175+
discriminator_owner=discriminator_owner,
1176+
)
1177+
else:
1178+
source = data_type.reference.source if data_type.reference else None
1179+
owner = discriminator_owner or (id(source) if source is not None else id(data_type))
1180+
if not isinstance(source, DataModel) or not (
1181+
isinstance(source, TypeAliasBase) or _is_pydantic_v2_root_model(source, pydantic_v2_root_model_type)
1182+
):
1183+
yield data_type, can_update_discriminator, owner
1184+
else:
1185+
source_id = id(source)
1186+
if active_union_models is None:
1187+
active_union_models = set()
1188+
if source_id in active_union_models or not source.fields:
1189+
yield data_type, can_update_discriminator, owner
1190+
else:
1191+
active_union_models.add(source_id)
1192+
try:
1193+
yield from _iter_discriminator_data_types(
1194+
(source.fields[0].data_type,),
1195+
pydantic_v2_root_model_type,
1196+
active_union_models,
1197+
can_update_discriminator=False,
1198+
discriminator_owner=owner,
1199+
)
1200+
finally:
1201+
active_union_models.remove(source_id)
1202+
1203+
1204+
def _discriminator_variants_are_valid(
1205+
data_types: Iterable[DataType],
1206+
field_name: str,
1207+
mapping: dict[str, str],
1208+
pydantic_v2_root_model_type: type[DataModel] | None,
1209+
) -> bool:
1210+
discriminator_value_owners: dict[DiscriminatorValue, int] = {}
1211+
for data_type, can_update_discriminator, owner in _iter_discriminator_data_types(
1212+
data_types, pydantic_v2_root_model_type
1213+
):
1214+
if not data_type.reference and data_type.type == NONE:
1215+
continue
1216+
if _is_discriminator_container(data_type) or not data_type.reference:
1217+
return False
1218+
discriminator_model = data_type.reference.source
1219+
if (
1220+
not isinstance(discriminator_model, DataModel)
1221+
or not discriminator_model.SUPPORTS_DISCRIMINATOR
1222+
or isinstance(discriminator_model, TypeAliasBase)
1223+
or _is_pydantic_v2_root_model(discriminator_model, pydantic_v2_root_model_type)
1224+
):
1225+
return False
1226+
1227+
discriminator_values = _get_discriminator_values(
1228+
discriminator_model,
1229+
field_name,
1230+
mapping,
1231+
require_literal=not can_update_discriminator,
1232+
)
1233+
if not discriminator_values:
1234+
return False
1235+
for value in discriminator_values:
1236+
if (previous_owner := discriminator_value_owners.get(value)) is not None and previous_owner != owner:
1237+
return False
1238+
discriminator_value_owners[value] = owner
1239+
return True
1240+
1241+
11031242
def _get_enum_from_base(discriminator_model: DataModel, field_name: str) -> Enum | None:
11041243
for base_class in discriminator_model.base_classes:
11051244
if not base_class.reference or not base_class.reference.source: # pragma: no cover
@@ -2051,12 +2190,11 @@ def __apply_discriminator_type( # noqa: PLR0912, PLR0914, PLR0915
20512190
) -> None:
20522191
for model in models: # noqa: PLR1702
20532192
for field in model.fields:
2054-
discriminator = field.extras.get("discriminator")
2055-
if not discriminator or not isinstance(discriminator, dict):
2056-
continue
2057-
property_name = discriminator.get("propertyName")
2058-
if not property_name: # pragma: no cover
2059-
continue
2193+
match field.extras.get("discriminator"):
2194+
case {"propertyName": str() as property_name} as discriminator if property_name:
2195+
pass
2196+
case _:
2197+
continue
20602198
field_name, alias = self.model_resolver.get_valid_field_name_and_alias(
20612199
field_name=property_name, model_type=self.field_name_model_type
20622200
)
@@ -2065,64 +2203,32 @@ def __apply_discriminator_type( # noqa: PLR0912, PLR0914, PLR0915
20652203
# Any type cannot be a discriminated union variant (Pydantic v2 rejects it)
20662204
has_any_variant = any(_is_any_variant(dt) for dt in field.data_type.data_types)
20672205
if has_any_variant: # pragma: no cover
2068-
field.extras.pop("discriminator", None)
2069-
field.data_type.discriminator = None
2206+
_remove_discriminator(field)
20702207
_clear_model_imports_cache_if_retained(model, can_retain_cache=can_retain_cache)
20712208
continue
2209+
if not _discriminator_variants_are_valid(
2210+
field.data_type.data_types,
2211+
field_name,
2212+
mapping,
2213+
self.pydantic_v2_root_model_type,
2214+
):
2215+
_remove_discriminator(field)
2216+
_clear_model_imports_cache_if_retained(model, can_retain_cache=can_retain_cache)
2217+
continue
2218+
20722219
for data_type in field.data_type.data_types:
20732220
if not data_type.reference: # pragma: no cover
20742221
continue
20752222
discriminator_model = data_type.reference.source
2076-
20772223
if (
2078-
not isinstance(discriminator_model, DataModel) or not discriminator_model.SUPPORTS_DISCRIMINATOR
2224+
not isinstance(discriminator_model, DataModel)
2225+
or not discriminator_model.SUPPORTS_DISCRIMINATOR
2226+
or isinstance(discriminator_model, TypeAliasBase)
2227+
or _is_pydantic_v2_root_model(discriminator_model, self.pydantic_v2_root_model_type)
20792228
): # pragma: no cover
20802229
continue
20812230

2082-
discriminator_values: list[DiscriminatorValue] = []
2083-
2084-
for discriminator_field in discriminator_model.fields:
2085-
if field_name not in {discriminator_field.original_name, discriminator_field.name}:
2086-
continue
2087-
discriminator_value = _get_discriminator_field_value(discriminator_field)
2088-
if discriminator_value is not None:
2089-
discriminator_values = [discriminator_value]
2090-
break
2091-
# Reuse models are created as empty subclasses with a "/reuse" path suffix.
2092-
# Scan inherited fields to recover the discriminator literal from the base.
2093-
if not discriminator_values and discriminator_model.path.endswith("/reuse"):
2094-
for discriminator_field in discriminator_model.iter_all_fields(): # pragma: no branch
2095-
if field_name not in {discriminator_field.original_name, discriminator_field.name}:
2096-
continue
2097-
discriminator_value = _get_discriminator_field_value(discriminator_field)
2098-
if discriminator_value is not None: # pragma: no branch
2099-
discriminator_values = [discriminator_value]
2100-
break
2101-
2102-
if not discriminator_values and mapping:
2103-
_check_discriminator_mapping_paths(
2104-
discriminator_model,
2105-
mapping,
2106-
discriminator_values,
2107-
)
2108-
2109-
if len(discriminator_values) == 0:
2110-
for base_class in discriminator_model.base_classes:
2111-
if not base_class.reference:
2112-
continue
2113-
2114-
_check_discriminator_mapping_paths(base_class.reference, mapping, discriminator_values)
2115-
2116-
if not discriminator_values:
2117-
discriminator_values = [discriminator_model.path.split("/")[-1]]
2118-
2119-
if not discriminator_values:
2120-
discriminator_values = [discriminator_model.path.split("/")[-1]]
2121-
2122-
if not discriminator_values: # pragma: no cover
2123-
msg = f"Discriminator type is not found. {data_type.reference.path}"
2124-
raise RuntimeError(msg)
2125-
2231+
discriminator_values = _get_discriminator_values(discriminator_model, field_name, mapping)
21262232
has_one_literal = False
21272233
for discriminator_field in discriminator_model.fields:
21282234
if field_name not in {discriminator_field.original_name, discriminator_field.name}:
@@ -2565,11 +2671,18 @@ def __collapse_root_models( # noqa: PLR0912, PLR0914, PLR0915
25652671
if isinstance(discriminator, dict)
25662672
else discriminator
25672673
)
2674+
mapping = discriminator.get("mapping", {}) if isinstance(discriminator, dict) else {}
25682675
field_name, _ = self.model_resolver.get_valid_field_name_and_alias(
25692676
field_name=prop_name,
25702677
model_type=self.field_name_model_type,
25712678
)
2572-
copied_data_type.discriminator = field_name
2679+
if _discriminator_variants_are_valid(
2680+
copied_data_type.data_types,
2681+
field_name,
2682+
mapping,
2683+
self.pydantic_v2_root_model_type,
2684+
):
2685+
copied_data_type.discriminator = field_name
25732686
assert isinstance(data_type.parent, DataType)
25742687
self.generation_store.replace_nested_data_type(data_type.parent, data_type, copied_data_type)
25752688

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# generated by datamodel-codegen:
2+
# filename: discriminator_duplicate_value.yaml
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Literal, Union
8+
9+
from pydantic import BaseModel, Field, RootModel
10+
11+
12+
class UserMessage(BaseModel):
13+
type: Literal['message']
14+
role: Literal['user']
15+
content: str
16+
17+
18+
class AssistantMessage(BaseModel):
19+
type: Literal['message']
20+
role: Literal['assistant']
21+
content: str
22+
23+
24+
class EasyInputMessage(BaseModel):
25+
type: Literal['message']
26+
role: Literal['easy']
27+
content: str
28+
29+
30+
class ItemReference(BaseModel):
31+
type: Literal['item_reference']
32+
id: str
33+
34+
35+
class Item(RootModel[UserMessage | AssistantMessage]):
36+
root: UserMessage | AssistantMessage
37+
38+
39+
class InputItem(RootModel[EasyInputMessage | Item | ItemReference]):
40+
root: EasyInputMessage | Item | ItemReference
41+
42+
43+
class GroupedItem(RootModel[Item | ItemReference]):
44+
root: Item | ItemReference = Field(..., discriminator='type')
45+
46+
47+
class MixedItem(RootModel[str | ItemReference | None]):
48+
root: str | ItemReference | None
49+
50+
51+
class MessageWrapper(RootModel[UserMessage]):
52+
root: UserMessage
53+
54+
55+
class WrappedItem(RootModel[MessageWrapper | UserMessage | ItemReference]):
56+
root: MessageWrapper | UserMessage | ItemReference
57+
58+
59+
class MissingType(BaseModel):
60+
value: str
61+
62+
63+
class MissingTypeWrapper(RootModel[MissingType]):
64+
root: MissingType
65+
66+
67+
class NestedMissingType(RootModel[MissingTypeWrapper | ItemReference]):
68+
root: MissingTypeWrapper | ItemReference
69+
70+
71+
class RecursiveItem(RootModel[Union["RecursiveItem", ItemReference]]):
72+
root: Union["RecursiveItem", ItemReference]
73+
74+
75+
class UniqueA(BaseModel):
76+
type: Literal['a']
77+
78+
79+
class UniqueB(BaseModel):
80+
type: Literal['b']
81+
82+
83+
class UniqueC(BaseModel):
84+
type: Literal['c']
85+
86+
87+
class UniqueInner(RootModel[UniqueA | UniqueB]):
88+
root: UniqueA | UniqueB = Field(..., discriminator='type')
89+
90+
91+
class UniqueOuter(RootModel[UniqueInner | UniqueC]):
92+
root: UniqueInner | UniqueC = Field(..., discriminator='type')
93+
94+
95+
class NullableUnique(RootModel[UniqueA | UniqueB | None]):
96+
root: UniqueA | UniqueB | None = Field(..., discriminator='type')
97+
98+
99+
class ContainerItem(RootModel[list[UniqueA | UniqueB] | ItemReference]):
100+
root: list[UniqueA | UniqueB] | ItemReference
101+
102+
103+
class Envelope(BaseModel):
104+
items: list[InputItem]
105+
grouped: GroupedItem
106+
mixed: MixedItem | None
107+
wrapped: WrappedItem
108+
nested_missing: NestedMissingType
109+
container: ContainerItem
110+
recursive: RecursiveItem
111+
unique: UniqueOuter
112+
nullable_unique: NullableUnique | None
113+
114+
115+
RecursiveItem.model_rebuild()

0 commit comments

Comments
 (0)