Skip to content

Commit bdf04bb

Browse files
authored
Fix enum generation from oneOf/anyOf with const values (#2634)
* Add support for extracting const enums from oneOf/anyOf schemas * Add tests and support for generating const enums from oneOf schemas * Add tests and JSON schemas for oneOf const enum generation * Add tests for oneOf and anyOf const enum generation scenarios * Add tests and implementations for oneOf const enum generation scenarios
1 parent 0b0e85f commit bdf04bb

31 files changed

Lines changed: 685 additions & 16 deletions

src/datamodel_code_generator/parser/jsonschema.py

Lines changed: 126 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,82 @@ def should_parse_enum_as_literal(self, obj: JsonSchemaObject) -> bool:
728728
self.enum_field_as_literal == LiteralType.One and len(obj.enum) == 1
729729
)
730730

731+
@classmethod
732+
def _extract_const_enum_from_combined( # noqa: PLR0912
733+
cls, items: list[JsonSchemaObject], parent_type: str | list[str] | None
734+
) -> tuple[list[Any], list[str], str | None, bool] | None:
735+
"""Extract enum values from oneOf/anyOf const pattern."""
736+
enum_values: list[Any] = []
737+
varnames: list[str] = []
738+
nullable = False
739+
inferred_type: str | None = None
740+
741+
for item in items:
742+
if item.type == "null" and "const" not in item.extras:
743+
nullable = True
744+
continue
745+
746+
if "const" not in item.extras:
747+
return None
748+
749+
if item.ref or item.properties or item.oneOf or item.anyOf or item.allOf:
750+
return None
751+
752+
const_value = item.extras["const"]
753+
enum_values.append(const_value)
754+
755+
if item.title:
756+
varnames.append(item.title)
757+
else:
758+
varnames.append(str(const_value))
759+
760+
if inferred_type is None and const_value is not None:
761+
if isinstance(const_value, str):
762+
inferred_type = "string"
763+
elif isinstance(const_value, bool):
764+
inferred_type = "boolean"
765+
elif isinstance(const_value, int):
766+
inferred_type = "integer"
767+
elif isinstance(const_value, float):
768+
inferred_type = "number"
769+
770+
if not enum_values: # pragma: no cover
771+
return None
772+
773+
final_type: str | None
774+
if isinstance(parent_type, str):
775+
final_type = parent_type
776+
elif isinstance(parent_type, list):
777+
non_null_types = [t for t in parent_type if t != "null"]
778+
final_type = non_null_types[0] if non_null_types else inferred_type
779+
if "null" in parent_type:
780+
nullable = True
781+
else:
782+
final_type = inferred_type
783+
784+
return (enum_values, varnames, final_type, nullable)
785+
786+
def _create_synthetic_enum_obj(
787+
self,
788+
original: JsonSchemaObject,
789+
enum_values: list[Any],
790+
varnames: list[str],
791+
enum_type: str | None,
792+
nullable: bool, # noqa: FBT001
793+
) -> JsonSchemaObject:
794+
"""Create a synthetic JsonSchemaObject for enum parsing."""
795+
final_enum = [*enum_values, None] if nullable else enum_values
796+
final_varnames = varnames if len(varnames) == len(enum_values) else []
797+
798+
return self.SCHEMA_OBJECT_TYPE(
799+
type=enum_type,
800+
enum=final_enum,
801+
title=original.title,
802+
description=original.description,
803+
x_enum_varnames=final_varnames,
804+
default=original.default if original.has_default else None,
805+
)
806+
731807
def is_constraints_field(self, obj: JsonSchemaObject) -> bool:
732808
"""Check if a field should include constraints."""
733809
return obj.is_array or (
@@ -1786,8 +1862,22 @@ def parse_item( # noqa: PLR0911, PLR0912
17861862
if item.discriminator and parent and parent.is_array and (item.oneOf or item.anyOf):
17871863
return self.parse_root_type(name, item, path)
17881864
if item.anyOf:
1865+
const_enum_data = self._extract_const_enum_from_combined(item.anyOf, item.type)
1866+
if const_enum_data is not None:
1867+
enum_values, varnames, enum_type, nullable = const_enum_data
1868+
synthetic_obj = self._create_synthetic_enum_obj(item, enum_values, varnames, enum_type, nullable)
1869+
if self.should_parse_enum_as_literal(synthetic_obj):
1870+
return self.parse_enum_as_literal(synthetic_obj)
1871+
return self.parse_enum(name, synthetic_obj, get_special_path("enum", path), singular_name=singular_name)
17891872
return self.data_type(data_types=self.parse_any_of(name, item, get_special_path("anyOf", path)))
17901873
if item.oneOf:
1874+
const_enum_data = self._extract_const_enum_from_combined(item.oneOf, item.type)
1875+
if const_enum_data is not None:
1876+
enum_values, varnames, enum_type, nullable = const_enum_data
1877+
synthetic_obj = self._create_synthetic_enum_obj(item, enum_values, varnames, enum_type, nullable)
1878+
if self.should_parse_enum_as_literal(synthetic_obj):
1879+
return self.parse_enum_as_literal(synthetic_obj)
1880+
return self.parse_enum(name, synthetic_obj, get_special_path("enum", path), singular_name=singular_name)
17911881
return self.data_type(data_types=self.parse_one_of(name, item, get_special_path("oneOf", path)))
17921882
if item.allOf:
17931883
all_of_path = get_special_path("allOf", path)
@@ -1964,7 +2054,7 @@ def parse_array(
19642054
self.results.append(data_model_root)
19652055
return self.data_type(reference=reference)
19662056

1967-
def parse_root_type( # noqa: PLR0912
2057+
def parse_root_type( # noqa: PLR0912, PLR0915
19682058
self,
19692059
name: str,
19702060
obj: JsonSchemaObject,
@@ -1983,18 +2073,28 @@ def parse_root_type( # noqa: PLR0912
19832073
name, obj, get_special_path("array", path)
19842074
).data_type # pragma: no cover
19852075
elif obj.anyOf or obj.oneOf:
1986-
reference = self.model_resolver.add(path, name, loaded=True, class_name=True)
1987-
if obj.anyOf:
1988-
data_types: list[DataType] = self.parse_any_of(name, obj, get_special_path("anyOf", path))
2076+
combined_items = obj.anyOf or obj.oneOf
2077+
const_enum_data = self._extract_const_enum_from_combined(combined_items, obj.type)
2078+
if const_enum_data is not None: # pragma: no cover
2079+
enum_values, varnames, enum_type, nullable = const_enum_data
2080+
synthetic_obj = self._create_synthetic_enum_obj(obj, enum_values, varnames, enum_type, nullable)
2081+
if self.should_parse_enum_as_literal(synthetic_obj):
2082+
data_type = self.parse_enum_as_literal(synthetic_obj)
2083+
else:
2084+
data_type = self.parse_enum(name, synthetic_obj, path)
19892085
else:
1990-
data_types = self.parse_one_of(name, obj, get_special_path("oneOf", path))
1991-
1992-
if len(data_types) > 1: # pragma: no cover
1993-
data_type = self.data_type(data_types=data_types)
1994-
elif not data_types: # pragma: no cover
1995-
return EmptyDataType()
1996-
else: # pragma: no cover
1997-
data_type = data_types[0]
2086+
reference = self.model_resolver.add(path, name, loaded=True, class_name=True)
2087+
if obj.anyOf:
2088+
data_types: list[DataType] = self.parse_any_of(name, obj, get_special_path("anyOf", path))
2089+
else:
2090+
data_types = self.parse_one_of(name, obj, get_special_path("oneOf", path))
2091+
2092+
if len(data_types) > 1: # pragma: no cover
2093+
data_type = self.data_type(data_types=data_types)
2094+
elif not data_types: # pragma: no cover
2095+
return EmptyDataType()
2096+
else: # pragma: no cover
2097+
data_type = data_types[0]
19982098
elif obj.patternProperties:
19992099
data_type = self.parse_pattern_properties(name, obj.patternProperties, path)
20002100
elif obj.enum:
@@ -2408,7 +2508,7 @@ def parse_raw_obj(
24082508
)
24092509
self.parse_obj(name, obj, path)
24102510

2411-
def parse_obj(
2511+
def parse_obj( # noqa: PLR0912
24122512
self,
24132513
name: str,
24142514
obj: JsonSchemaObject,
@@ -2420,9 +2520,19 @@ def parse_obj(
24202520
elif obj.allOf:
24212521
self.parse_all_of(name, obj, path)
24222522
elif obj.oneOf or obj.anyOf:
2423-
data_type = self.parse_root_type(name, obj, path)
2424-
if isinstance(data_type, EmptyDataType) and obj.properties:
2425-
self.parse_object(name, obj, path) # pragma: no cover
2523+
combined_items = obj.oneOf or obj.anyOf
2524+
const_enum_data = self._extract_const_enum_from_combined(combined_items, obj.type)
2525+
if const_enum_data is not None:
2526+
enum_values, varnames, enum_type, nullable = const_enum_data
2527+
synthetic_obj = self._create_synthetic_enum_obj(obj, enum_values, varnames, enum_type, nullable)
2528+
if not self.should_parse_enum_as_literal(synthetic_obj):
2529+
self.parse_enum(name, synthetic_obj, path)
2530+
else:
2531+
self.parse_root_type(name, synthetic_obj, path)
2532+
else:
2533+
data_type = self.parse_root_type(name, obj, path)
2534+
if isinstance(data_type, EmptyDataType) and obj.properties:
2535+
self.parse_object(name, obj, path) # pragma: no cover
24262536
elif obj.properties:
24272537
if obj.has_multiple_types and isinstance(obj.type, list):
24282538
self._parse_multiple_types_with_properties(name, obj, obj.type, path)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# generated by datamodel-codegen:
2+
# filename: anyof_const_enum_nested.yaml
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from enum import Enum
8+
from typing import List, Optional
9+
10+
from pydantic import BaseModel, Field
11+
12+
13+
class Mode(Enum):
14+
fast = 'fast'
15+
slow = 'slow'
16+
17+
18+
class Mode1(Enum):
19+
a = 'a'
20+
b = 'b'
21+
22+
23+
class Config(BaseModel):
24+
mode: Optional[Mode] = Field(None, title='Mode')
25+
modes: Optional[List[Mode1]] = None
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# generated by datamodel-codegen:
2+
# filename: anyof_const_enum_nested.yaml
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import List, Literal, Optional
8+
9+
from pydantic import BaseModel, Field
10+
11+
12+
class Config(BaseModel):
13+
mode: Optional[Literal['fast', 'slow']] = Field(None, title='Mode')
14+
modes: Optional[List[Literal['a', 'b']]] = None
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# generated by datamodel-codegen:
2+
# filename: oneof_const_enum.yaml
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from enum import Enum
8+
9+
10+
class NodejsMode(Enum):
11+
npm = 'npm'
12+
yarn = 'yarn'
13+
npm_ci = 'npm_ci'
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# generated by datamodel-codegen:
2+
# filename: oneof_const_enum_bool.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from enum import Enum
8+
9+
10+
class BooleanFlag(Enum):
11+
boolean_True = True
12+
boolean_False = False
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# generated by datamodel-codegen:
2+
# filename: oneof_const_enum_float.json
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from enum import Enum
8+
9+
10+
class Ratio(Enum):
11+
number_0_5 = 0.5
12+
number_1_0 = 1.0
13+
number_1_5 = 1.5
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# generated by datamodel-codegen:
2+
# filename: oneof_const_enum_infer_type.yaml
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from enum import Enum
8+
9+
10+
class InferredType(Enum):
11+
value1 = 'value1'
12+
value2 = 'value2'
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# generated by datamodel-codegen:
2+
# filename: oneof_const_enum_int.yaml
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from enum import IntEnum
8+
9+
10+
class StatusCode(IntEnum):
11+
integer_200 = 200
12+
integer_404 = 404
13+
integer_500 = 500
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# generated by datamodel-codegen:
2+
# filename: oneof_const_enum.yaml
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from typing import Literal
8+
9+
from pydantic import BaseModel, Field
10+
11+
12+
class NodejsMode(BaseModel):
13+
__root__: Literal['npm', 'yarn', 'npm_ci'] = Field(..., title='NodeJS mode')
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# generated by datamodel-codegen:
2+
# filename: oneof_const_enum_nested.yaml
3+
# timestamp: 2019-07-26T00:00:00+00:00
4+
5+
from __future__ import annotations
6+
7+
from enum import Enum
8+
from typing import List, Optional
9+
10+
from pydantic import BaseModel, Field
11+
12+
13+
class Mode(Enum):
14+
fast = 'fast'
15+
slow = 'slow'
16+
17+
18+
class Mode1(Enum):
19+
a = 'a'
20+
b = 'b'
21+
22+
23+
class Config(BaseModel):
24+
mode: Optional[Mode] = Field(None, title='Mode')
25+
modes: Optional[List[Mode1]] = None

0 commit comments

Comments
 (0)