Skip to content

Commit fffeaea

Browse files
committed
fix class generation for nullable enums
1 parent 3fb5fb2 commit fffeaea

File tree

8 files changed

+155
-26
lines changed

8 files changed

+155
-26
lines changed

end_to_end_tests/baseline_openapi_3.0.json

+12-1
Original file line numberDiff line numberDiff line change
@@ -1660,7 +1660,9 @@
16601660
"model",
16611661
"nullable_model",
16621662
"one_of_models",
1663-
"nullable_one_of_models"
1663+
"nullable_one_of_models",
1664+
"nullable_enum_as_ref",
1665+
"nullable_enum_inline"
16641666
],
16651667
"type": "object",
16661668
"properties": {
@@ -1830,6 +1832,14 @@
18301832
}
18311833
],
18321834
"nullable": true
1835+
},
1836+
"nullable_enum_as_ref": {
1837+
"$ref": "#/components/schemas/AnEnumWithNull"
1838+
},
1839+
"nullable_enum_inline": {
1840+
"type": "string",
1841+
"enum": ["FIRST_VALUE", "SECOND_VALUE", null],
1842+
"nullable": true
18331843
}
18341844
},
18351845
"description": "A Model for testing all the ways custom objects can be used ",
@@ -1850,6 +1860,7 @@
18501860
"SECOND_VALUE",
18511861
null
18521862
],
1863+
"nullable": true,
18531864
"description": "For testing Enums with mixed string / null values "
18541865
},
18551866
"AnEnumWithOnlyNull": {

end_to_end_tests/baseline_openapi_3.1.yaml

+11-1
Original file line numberDiff line numberDiff line change
@@ -1650,7 +1650,9 @@ info:
16501650
"model",
16511651
"nullable_model",
16521652
"one_of_models",
1653-
"nullable_one_of_models"
1653+
"nullable_one_of_models",
1654+
"nullable_enum_as_ref",
1655+
"nullable_enum_inline"
16541656
],
16551657
"type": "object",
16561658
"properties": {
@@ -1840,6 +1842,13 @@ info:
18401842
"$ref": "#/components/schemas/ModelWithUnionProperty"
18411843
}
18421844
]
1845+
},
1846+
"nullable_enum_as_ref": {
1847+
"$ref": "#/components/schemas/AnEnumWithNull"
1848+
},
1849+
"nullable_enum_inline": {
1850+
"type": ["string", "null"],
1851+
"enum": ["FIRST_VALUE", "SECOND_VALUE", null]
18431852
}
18441853
},
18451854
"description": "A Model for testing all the ways custom objects can be used ",
@@ -1855,6 +1864,7 @@ info:
18551864
},
18561865
"AnEnumWithNull": {
18571866
"title": "AnEnumWithNull",
1867+
"type": ["string", "null"],
18581868
"enum": [
18591869
"FIRST_VALUE",
18601870
"SECOND_VALUE",

end_to_end_tests/golden-record/my_test_api_client/models/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .a_discriminated_union_type_2 import ADiscriminatedUnionType2
55
from .a_form_data import AFormData
66
from .a_model import AModel
7+
from .a_model_nullable_enum_inline import AModelNullableEnumInline
78
from .a_model_with_properties_reference_that_are_not_object import AModelWithPropertiesReferenceThatAreNotObject
89
from .all_of_has_properties_but_no_type import AllOfHasPropertiesButNoType
910
from .all_of_has_properties_but_no_type_type_enum import AllOfHasPropertiesButNoTypeTypeEnum
@@ -91,6 +92,7 @@
9192
"AllOfSubModel",
9293
"AllOfSubModelTypeEnum",
9394
"AModel",
95+
"AModelNullableEnumInline",
9496
"AModelWithPropertiesReferenceThatAreNotObject",
9597
"AnAllOfEnum",
9698
"AnArrayWithACircularRefInItemsObjectAdditionalPropertiesAItem",

end_to_end_tests/golden-record/my_test_api_client/models/a_model.py

+52
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
from attrs import define as _attrs_define
55
from dateutil.parser import isoparse
66

7+
from ..models.a_model_nullable_enum_inline import AModelNullableEnumInline
78
from ..models.an_all_of_enum import AnAllOfEnum
89
from ..models.an_enum import AnEnum
10+
from ..models.an_enum_with_null import AnEnumWithNull
911
from ..models.different_enum import DifferentEnum
1012
from ..types import UNSET, Unset
1113

@@ -33,6 +35,8 @@ class AModel:
3335
nullable_one_of_models (Union['FreeFormModel', 'ModelWithUnionProperty', None]):
3436
model (ModelWithUnionProperty):
3537
nullable_model (Union['ModelWithUnionProperty', None]):
38+
nullable_enum_as_ref (Union[AnEnumWithNull, None]): For testing Enums with mixed string / null values
39+
nullable_enum_inline (Union[AModelNullableEnumInline, None]):
3640
any_value (Union[Unset, Any]):
3741
an_optional_allof_enum (Union[Unset, AnAllOfEnum]):
3842
nested_list_of_enums (Union[Unset, List[List[DifferentEnum]]]):
@@ -57,6 +61,8 @@ class AModel:
5761
nullable_one_of_models: Union["FreeFormModel", "ModelWithUnionProperty", None]
5862
model: "ModelWithUnionProperty"
5963
nullable_model: Union["ModelWithUnionProperty", None]
64+
nullable_enum_as_ref: Union[AnEnumWithNull, None]
65+
nullable_enum_inline: Union[AModelNullableEnumInline, None]
6066
an_allof_enum_with_overridden_default: AnAllOfEnum = AnAllOfEnum.OVERRIDDEN_DEFAULT
6167
any_value: Union[Unset, Any] = UNSET
6268
an_optional_allof_enum: Union[Unset, AnAllOfEnum] = UNSET
@@ -122,6 +128,18 @@ def to_dict(self) -> Dict[str, Any]:
122128
else:
123129
nullable_model = self.nullable_model
124130

131+
nullable_enum_as_ref: Union[None, str]
132+
if isinstance(self.nullable_enum_as_ref, AnEnumWithNull):
133+
nullable_enum_as_ref = self.nullable_enum_as_ref.value
134+
else:
135+
nullable_enum_as_ref = self.nullable_enum_as_ref
136+
137+
nullable_enum_inline: Union[None, str]
138+
if isinstance(self.nullable_enum_inline, AModelNullableEnumInline):
139+
nullable_enum_inline = self.nullable_enum_inline.value
140+
else:
141+
nullable_enum_inline = self.nullable_enum_inline
142+
125143
any_value = self.any_value
126144

127145
an_optional_allof_enum: Union[Unset, str] = UNSET
@@ -199,6 +217,8 @@ def to_dict(self) -> Dict[str, Any]:
199217
"nullable_one_of_models": nullable_one_of_models,
200218
"model": model,
201219
"nullable_model": nullable_model,
220+
"nullable_enum_as_ref": nullable_enum_as_ref,
221+
"nullable_enum_inline": nullable_enum_inline,
202222
}
203223
)
204224
if any_value is not UNSET:
@@ -342,6 +362,36 @@ def _parse_nullable_model(data: object) -> Union["ModelWithUnionProperty", None]
342362

343363
nullable_model = _parse_nullable_model(d.pop("nullable_model"))
344364

365+
def _parse_nullable_enum_as_ref(data: object) -> Union[AnEnumWithNull, None]:
366+
if data is None:
367+
return data
368+
try:
369+
if not isinstance(data, str):
370+
raise TypeError()
371+
componentsschemas_an_enum_with_null = AnEnumWithNull(data)
372+
373+
return componentsschemas_an_enum_with_null
374+
except: # noqa: E722
375+
pass
376+
return cast(Union[AnEnumWithNull, None], data)
377+
378+
nullable_enum_as_ref = _parse_nullable_enum_as_ref(d.pop("nullable_enum_as_ref"))
379+
380+
def _parse_nullable_enum_inline(data: object) -> Union[AModelNullableEnumInline, None]:
381+
if data is None:
382+
return data
383+
try:
384+
if not isinstance(data, str):
385+
raise TypeError()
386+
nullable_enum_inline = AModelNullableEnumInline(data)
387+
388+
return nullable_enum_inline
389+
except: # noqa: E722
390+
pass
391+
return cast(Union[AModelNullableEnumInline, None], data)
392+
393+
nullable_enum_inline = _parse_nullable_enum_inline(d.pop("nullable_enum_inline"))
394+
345395
any_value = d.pop("any_value", UNSET)
346396

347397
_an_optional_allof_enum = d.pop("an_optional_allof_enum", UNSET)
@@ -469,6 +519,8 @@ def _parse_not_required_nullable_model(data: object) -> Union["ModelWithUnionPro
469519
nullable_one_of_models=nullable_one_of_models,
470520
model=model,
471521
nullable_model=nullable_model,
522+
nullable_enum_as_ref=nullable_enum_as_ref,
523+
nullable_enum_inline=nullable_enum_inline,
472524
any_value=any_value,
473525
an_optional_allof_enum=an_optional_allof_enum,
474526
nested_list_of_enums=nested_list_of_enums,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from enum import Enum
2+
3+
4+
class AModelNullableEnumInline(str, Enum):
5+
FIRST_VALUE = "FIRST_VALUE"
6+
SECOND_VALUE = "SECOND_VALUE"
7+
8+
def __str__(self) -> str:
9+
return str(self.value)

openapi_python_client/parser/properties/enum_property.py

+34-19
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99

1010
from ... import Config, utils
1111
from ... import schema as oai
12-
from ...schema import DataType
1312
from ..errors import PropertyError
1413
from .none import NoneProperty
1514
from .protocol import PropertyProtocol, Value
@@ -43,7 +42,7 @@ class EnumProperty(PropertyProtocol):
4342
}
4443

4544
@classmethod
46-
def build( # noqa: PLR0911
45+
def build(
4746
cls,
4847
*,
4948
data: oai.Schema,
@@ -75,9 +74,10 @@ def build( # noqa: PLR0911
7574
# So instead, if null is a possible value, make the property nullable.
7675
# Mypy is not smart enough to know that the type is right though
7776
unchecked_value_list = [value for value in enum if value is not None] # type: ignore
77+
allow_null = len(unchecked_value_list) < len(enum)
7878

7979
# It's legal to have an enum that only contains null as a value, we don't bother constructing an enum for that
80-
if len(unchecked_value_list) == 0:
80+
if len(unchecked_value_list) == 0 and allow_null:
8181
return (
8282
NoneProperty.build(
8383
name=name,
@@ -102,21 +102,6 @@ def build( # noqa: PLR0911
102102
Union[List[int], List[str]], unchecked_value_list
103103
) # We checked this with all the value_types stuff
104104

105-
if len(value_list) < len(enum): # Only one of the values was None, that becomes a union
106-
data.oneOf = [
107-
oai.Schema(type=DataType.NULL),
108-
data.model_copy(update={"enum": value_list, "default": data.default}),
109-
]
110-
data.enum = None
111-
return UnionProperty.build(
112-
data=data,
113-
name=name,
114-
required=required,
115-
schemas=schemas,
116-
parent_name=parent_name,
117-
config=config,
118-
)
119-
120105
class_name = data.title or name
121106
if parent_name:
122107
class_name = f"{utils.pascal_case(parent_name)}{utils.pascal_case(class_name)}"
@@ -150,8 +135,38 @@ def build( # noqa: PLR0911
150135
return checked_default, schemas
151136
prop = evolve(prop, default=checked_default)
152137

138+
# Now, if one of the values was None, wrap the type we just made in a union with None. We're
139+
# constructing the UnionProperty directly instead of using UnionProperty.build() because we
140+
# do *not* want the usual union behavior of creating ThingType1, ThingType2, etc.
141+
returned_prop: EnumProperty | UnionProperty | PropertyError
142+
if allow_null:
143+
none_prop = NoneProperty.build(
144+
name=name,
145+
required=required,
146+
default=None,
147+
python_name=prop.python_name,
148+
description=None,
149+
example=None,
150+
)
151+
assert not isinstance(none_prop, PropertyError)
152+
union_prop = UnionProperty(
153+
name=name,
154+
required=required,
155+
default=checked_default,
156+
python_name=prop.python_name,
157+
description=data.description,
158+
example=data.example,
159+
inner_properties=[
160+
none_prop,
161+
prop,
162+
],
163+
)
164+
returned_prop = union_prop
165+
else:
166+
returned_prop = prop
167+
153168
schemas = evolve(schemas, classes_by_name={**schemas.classes_by_name, class_info.name: prop})
154-
return prop, schemas
169+
return returned_prop, schemas
155170

156171
def convert_value(self, value: Any) -> Value | PropertyError | None:
157172
if value is None or isinstance(value, Value):

tests/test_parser/test_properties/test_enum_property.py

+21
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import openapi_python_client.schema as oai
22
from openapi_python_client.parser.errors import PropertyError
33
from openapi_python_client.parser.properties import EnumProperty, Schemas
4+
from openapi_python_client.parser.properties.none import NoneProperty
5+
from openapi_python_client.parser.properties.union import UnionProperty
46

57

68
def test_conflict(config):
@@ -66,3 +68,22 @@ def test_unsupported_type(config):
6668
)
6769

6870
assert isinstance(err, PropertyError)
71+
72+
73+
def test_nullable_enum(config):
74+
data = oai.Schema(
75+
type="string",
76+
enum=["a", "b", None],
77+
nullable=True,
78+
)
79+
schemas = Schemas()
80+
81+
p, _ = EnumProperty.build(
82+
data=data, name="prop1", required=True, schemas=schemas, parent_name="parent", config=config
83+
)
84+
85+
assert isinstance(p, UnionProperty)
86+
assert len(p.inner_properties) == 2 # noqa: PLR2004
87+
assert isinstance(p.inner_properties[0], NoneProperty)
88+
assert isinstance(p.inner_properties[1], EnumProperty)
89+
assert p.inner_properties[1].class_info.name == "ParentProp1"

tests/test_parser/test_properties/test_init.py

+14-5
Original file line numberDiff line numberDiff line change
@@ -403,14 +403,23 @@ def test_property_from_data_str_enum(self, enum_property_factory, config):
403403
"ParentAnEnum": prop,
404404
}
405405

406+
@pytest.mark.parametrize(
407+
"desc,extra_props",
408+
[
409+
("3_1_implicit_type", {}),
410+
("3_1_explicit_type", {"type": ["string", "null"]}),
411+
("3_0_implicit_type", {"nullable": True}),
412+
("3_0_explicit_type", {"type": "string", "nullable": True}),
413+
],
414+
)
406415
def test_property_from_data_str_enum_with_null(
407-
self, enum_property_factory, union_property_factory, none_property_factory, config
416+
self, desc, extra_props, enum_property_factory, union_property_factory, none_property_factory, config
408417
):
409418
from openapi_python_client.parser.properties import Class, Schemas, property_from_data
410419
from openapi_python_client.schema import Schema
411420

412421
existing = enum_property_factory()
413-
data = Schema(title="AnEnum", enum=["A", "B", "C", None], default="B")
422+
data = Schema(title="AnEnum", enum=["A", "B", "C", None], default="B", **extra_props)
414423
name = "my_enum"
415424
required = True
416425

@@ -423,16 +432,16 @@ def test_property_from_data_str_enum_with_null(
423432
# None / null is removed from enum, and property is now nullable
424433
assert isinstance(prop, UnionProperty), "Enums with None should be converted to UnionProperties"
425434
enum_prop = enum_property_factory(
426-
name="my_enum_type_1",
435+
name=name,
427436
required=required,
428437
values={"A": "A", "B": "B", "C": "C"},
429438
class_info=Class(name="ParentAnEnum", module_name="parent_an_enum"),
430439
value_type=str,
431440
default="ParentAnEnum.B",
432441
)
433-
none_property = none_property_factory(name="my_enum_type_0", required=required)
442+
none_property = none_property_factory(name=name, required=required)
434443
assert prop == union_property_factory(
435-
name=name, default="ParentAnEnum.B", inner_properties=[none_property, enum_prop]
444+
name=name, default="ParentAnEnum.B", inner_properties=[none_property, enum_prop], required=required
436445
)
437446
assert schemas != new_schemas, "Provided Schemas was mutated"
438447
assert new_schemas.classes_by_name == {

0 commit comments

Comments
 (0)