Skip to content

Commit 6e9d81f

Browse files
dbantyjuspence
andauthored
fix: Allow None in enum properties [openapi-generators#504, openapi-generators#512, openapi-generators#516]. Thanks @juspence!
Co-authored-by: juspence <[email protected]>
1 parent 2f82310 commit 6e9d81f

File tree

8 files changed

+176
-15
lines changed

8 files changed

+176
-15
lines changed

end_to_end_tests/golden-record/my_test_api_client/api/tests/get_user_list.py

+31
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from ...client import Client
77
from ...models.a_model import AModel
88
from ...models.an_enum import AnEnum
9+
from ...models.an_enum_with_null import AnEnumWithNull
910
from ...models.http_validation_error import HTTPValidationError
1011
from ...types import UNSET, Response
1112

@@ -14,6 +15,8 @@ def _get_kwargs(
1415
*,
1516
client: Client,
1617
an_enum_value: List[AnEnum],
18+
an_enum_value_with_null: List[Optional[AnEnumWithNull]],
19+
an_enum_value_with_only_null: List[None],
1720
some_date: Union[datetime.date, datetime.datetime],
1821
) -> Dict[str, Any]:
1922
url = "{}/tests/".format(client.base_url)
@@ -27,13 +30,25 @@ def _get_kwargs(
2730

2831
json_an_enum_value.append(an_enum_value_item)
2932

33+
json_an_enum_value_with_null = []
34+
for an_enum_value_with_null_item_data in an_enum_value_with_null:
35+
an_enum_value_with_null_item = (
36+
an_enum_value_with_null_item_data.value if an_enum_value_with_null_item_data else None
37+
)
38+
39+
json_an_enum_value_with_null.append(an_enum_value_with_null_item)
40+
41+
json_an_enum_value_with_only_null = an_enum_value_with_only_null
42+
3043
if isinstance(some_date, datetime.date):
3144
json_some_date = some_date.isoformat()
3245
else:
3346
json_some_date = some_date.isoformat()
3447

3548
params: Dict[str, Any] = {
3649
"an_enum_value": json_an_enum_value,
50+
"an_enum_value_with_null": json_an_enum_value_with_null,
51+
"an_enum_value_with_only_null": json_an_enum_value_with_only_null,
3752
"some_date": json_some_date,
3853
}
3954
params = {k: v for k, v in params.items() if v is not UNSET and v is not None}
@@ -82,11 +97,15 @@ def sync_detailed(
8297
*,
8398
client: Client,
8499
an_enum_value: List[AnEnum],
100+
an_enum_value_with_null: List[Optional[AnEnumWithNull]],
101+
an_enum_value_with_only_null: List[None],
85102
some_date: Union[datetime.date, datetime.datetime],
86103
) -> Response[Union[HTTPValidationError, List[AModel]]]:
87104
kwargs = _get_kwargs(
88105
client=client,
89106
an_enum_value=an_enum_value,
107+
an_enum_value_with_null=an_enum_value_with_null,
108+
an_enum_value_with_only_null=an_enum_value_with_only_null,
90109
some_date=some_date,
91110
)
92111

@@ -101,13 +120,17 @@ def sync(
101120
*,
102121
client: Client,
103122
an_enum_value: List[AnEnum],
123+
an_enum_value_with_null: List[Optional[AnEnumWithNull]],
124+
an_enum_value_with_only_null: List[None],
104125
some_date: Union[datetime.date, datetime.datetime],
105126
) -> Optional[Union[HTTPValidationError, List[AModel]]]:
106127
"""Get a list of things"""
107128

108129
return sync_detailed(
109130
client=client,
110131
an_enum_value=an_enum_value,
132+
an_enum_value_with_null=an_enum_value_with_null,
133+
an_enum_value_with_only_null=an_enum_value_with_only_null,
111134
some_date=some_date,
112135
).parsed
113136

@@ -116,11 +139,15 @@ async def asyncio_detailed(
116139
*,
117140
client: Client,
118141
an_enum_value: List[AnEnum],
142+
an_enum_value_with_null: List[Optional[AnEnumWithNull]],
143+
an_enum_value_with_only_null: List[None],
119144
some_date: Union[datetime.date, datetime.datetime],
120145
) -> Response[Union[HTTPValidationError, List[AModel]]]:
121146
kwargs = _get_kwargs(
122147
client=client,
123148
an_enum_value=an_enum_value,
149+
an_enum_value_with_null=an_enum_value_with_null,
150+
an_enum_value_with_only_null=an_enum_value_with_only_null,
124151
some_date=some_date,
125152
)
126153

@@ -134,6 +161,8 @@ async def asyncio(
134161
*,
135162
client: Client,
136163
an_enum_value: List[AnEnum],
164+
an_enum_value_with_null: List[Optional[AnEnumWithNull]],
165+
an_enum_value_with_only_null: List[None],
137166
some_date: Union[datetime.date, datetime.datetime],
138167
) -> Optional[Union[HTTPValidationError, List[AModel]]]:
139168
"""Get a list of things"""
@@ -142,6 +171,8 @@ async def asyncio(
142171
await asyncio_detailed(
143172
client=client,
144173
an_enum_value=an_enum_value,
174+
an_enum_value_with_null=an_enum_value_with_null,
175+
an_enum_value_with_only_null=an_enum_value_with_only_null,
145176
some_date=some_date,
146177
)
147178
).parsed

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

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from .all_of_sub_model_type_enum import AllOfSubModelTypeEnum
88
from .an_all_of_enum import AnAllOfEnum
99
from .an_enum import AnEnum
10+
from .an_enum_with_null import AnEnumWithNull
1011
from .an_int_enum import AnIntEnum
1112
from .another_all_of_sub_model import AnotherAllOfSubModel
1213
from .another_all_of_sub_model_type import AnotherAllOfSubModelType
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from enum import Enum
2+
3+
4+
class AnEnumWithNull(str, Enum):
5+
FIRST_VALUE = "FIRST_VALUE"
6+
SECOND_VALUE = "SECOND_VALUE"
7+
8+
def __str__(self) -> str:
9+
return str(self.value)

end_to_end_tests/openapi.json

+40
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,30 @@
2727
"name": "an_enum_value",
2828
"in": "query"
2929
},
30+
{
31+
"required": true,
32+
"schema": {
33+
"title": "An Enum Value With Null And String Values",
34+
"type": "array",
35+
"items": {
36+
"$ref": "#/components/schemas/AnEnumWithNull"
37+
}
38+
},
39+
"name": "an_enum_value_with_null",
40+
"in": "query"
41+
},
42+
{
43+
"required": true,
44+
"schema": {
45+
"title": "An Enum Value With Only Null Values",
46+
"type": "array",
47+
"items": {
48+
"$ref": "#/components/schemas/AnEnumWithOnlyNull"
49+
}
50+
},
51+
"name": "an_enum_value_with_only_null",
52+
"in": "query"
53+
},
3054
{
3155
"required": true,
3256
"schema": {
@@ -1164,6 +1188,22 @@
11641188
],
11651189
"description": "For testing Enums in all the ways they can be used "
11661190
},
1191+
"AnEnumWithNull": {
1192+
"title": "AnEnumWithNull",
1193+
"enum": [
1194+
"FIRST_VALUE",
1195+
"SECOND_VALUE",
1196+
null
1197+
],
1198+
"description": "For testing Enums with mixed string / null values "
1199+
},
1200+
"AnEnumWithOnlyNull": {
1201+
"title": "AnEnumWithOnlyNull",
1202+
"enum": [
1203+
null
1204+
],
1205+
"description": "For testing Enums with only null values "
1206+
},
11671207
"AnAllOfEnum": {
11681208
"title": "AnAllOfEnum",
11691209
"enum": [

openapi_python_client/parser/properties/__init__.py

+35-8
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ class AnyProperty(Property):
3434
template: ClassVar[Optional[str]] = "any_property.py.jinja"
3535

3636

37+
@attr.s(auto_attribs=True, frozen=True)
38+
class NoneProperty(Property):
39+
"""A property that can only be None"""
40+
41+
_type_string: ClassVar[str] = "None"
42+
_json_type_string: ClassVar[str] = "None"
43+
44+
3745
@attr.s(auto_attribs=True, frozen=True)
3846
class StringProperty(Property):
3947
"""A property of type str"""
@@ -296,10 +304,10 @@ def build_enum_property(
296304
name: str,
297305
required: bool,
298306
schemas: Schemas,
299-
enum: Union[List[str], List[int]],
307+
enum: Union[List[Optional[str]], List[Optional[int]]],
300308
parent_name: Optional[str],
301309
config: Config,
302-
) -> Tuple[Union[EnumProperty, PropertyError], Schemas]:
310+
) -> Tuple[Union[EnumProperty, NoneProperty, PropertyError], Schemas]:
303311
"""
304312
Create an EnumProperty from schema data.
305313
@@ -316,11 +324,34 @@ def build_enum_property(
316324
A tuple containing either the created property or a PropertyError describing what went wrong AND update schemas.
317325
"""
318326

327+
if len(enum) == 0:
328+
return PropertyError(detail="No values provided for Enum", data=data), schemas
329+
319330
class_name = data.title or name
320331
if parent_name:
321332
class_name = f"{utils.pascal_case(parent_name)}{utils.pascal_case(class_name)}"
322333
class_info = Class.from_string(string=class_name, config=config)
323-
values = EnumProperty.values_from_list(enum)
334+
335+
# OpenAPI allows for null as an enum value, but it doesn't make sense with how enums are constructed in Python.
336+
# So instead, if null is a possible value, make the property nullable.
337+
# Mypy is not smart enough to know that the type is right though
338+
value_list: Union[List[str], List[int]] = [value for value in enum if value is not None] # type: ignore
339+
if len(value_list) < len(enum):
340+
data.nullable = True
341+
342+
# It's legal to have an enum that only contains null as a value, we don't bother constructing an enum in that case
343+
if len(value_list) == 0:
344+
return (
345+
NoneProperty(
346+
name=name,
347+
required=required,
348+
nullable=False,
349+
default="None",
350+
python_name=utils.PythonIdentifier(value=name, prefix=config.field_prefix),
351+
),
352+
schemas,
353+
)
354+
values = EnumProperty.values_from_list(value_list)
324355

325356
if class_info.name in schemas.classes_by_name:
326357
existing = schemas.classes_by_name[class_info.name]
@@ -332,11 +363,7 @@ def build_enum_property(
332363
schemas,
333364
)
334365

335-
for value in values.values():
336-
value_type = type(value)
337-
break
338-
else:
339-
return PropertyError(data=data, detail="No values provided for Enum"), schemas
366+
value_type = type(next(iter(values.values())))
340367

341368
prop = EnumProperty(
342369
name=name,

openapi_python_client/parser/properties/enum_property.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def get_imports(self, *, prefix: str) -> Set[str]:
4242

4343
@staticmethod
4444
def values_from_list(values: Union[List[str], List[int]]) -> Dict[str, ValueType]:
45-
"""Convert a list of values into dict of {name: value}"""
45+
"""Convert a list of values into dict of {name: value}, where value can sometimes be None"""
4646
output: Dict[str, ValueType] = {}
4747

4848
for i, value in enumerate(values):

openapi_python_client/schema/openapi_schema_pydantic/schema.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ class Schema(BaseModel):
3535
maxProperties: Optional[int] = Field(default=None, ge=0)
3636
minProperties: Optional[int] = Field(default=None, ge=0)
3737
required: Optional[List[str]] = Field(default=None, min_items=1)
38-
enum: Union[None, List[StrictInt], List[StrictStr]] = Field(default=None, min_items=1)
38+
enum: Union[None, List[Optional[StrictInt]], List[Optional[StrictStr]]] = Field(default=None, min_items=1)
3939
type: Optional[DataType] = Field(default=None)
4040
allOf: Optional[List[Union[Reference, "Schema"]]] = None
4141
oneOf: List[Union[Reference, "Schema"]] = []

tests/test_parser/test_properties/test_init.py

+58-5
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import openapi_python_client.schema as oai
77
from openapi_python_client import Config
88
from openapi_python_client.parser.errors import PropertyError, ValidationError
9-
from openapi_python_client.parser.properties import BooleanProperty, FloatProperty, IntProperty, Schemas
9+
from openapi_python_client.parser.properties import BooleanProperty, FloatProperty, IntProperty, NoneProperty, Schemas
1010

1111
MODULE_NAME = "openapi_python_client.parser.properties"
1212

@@ -304,8 +304,58 @@ def test_property_from_data_str_enum(self, enum_property_factory):
304304
"ParentAnEnum": prop,
305305
}
306306

307+
def test_property_from_data_str_enum_with_null(self, enum_property_factory):
308+
from openapi_python_client.parser.properties import Class, Schemas, property_from_data
309+
from openapi_python_client.schema import Schema
310+
311+
existing = enum_property_factory()
312+
data = Schema(title="AnEnum", enum=["A", "B", "C", None], nullable=False, default="B")
313+
name = "my_enum"
314+
required = True
315+
316+
schemas = Schemas(classes_by_name={"AnEnum": existing})
317+
318+
prop, new_schemas = property_from_data(
319+
name=name, required=required, data=data, schemas=schemas, parent_name="parent", config=Config()
320+
)
321+
322+
# None / null is removed from enum, and property is now nullable
323+
assert prop == enum_property_factory(
324+
name=name,
325+
required=required,
326+
nullable=True,
327+
values={"A": "A", "B": "B", "C": "C"},
328+
class_info=Class(name="ParentAnEnum", module_name="parent_an_enum"),
329+
value_type=str,
330+
default="ParentAnEnum.B",
331+
)
332+
assert prop.nullable is True
333+
assert schemas != new_schemas, "Provided Schemas was mutated"
334+
assert new_schemas.classes_by_name == {
335+
"AnEnum": existing,
336+
"ParentAnEnum": prop,
337+
}
338+
339+
def test_property_from_data_null_enum(self, enum_property_factory):
340+
from openapi_python_client.parser.properties import Class, Schemas, property_from_data
341+
from openapi_python_client.schema import Schema
342+
343+
data = Schema(title="AnEnumWithOnlyNull", enum=[None], nullable=False, default=None)
344+
name = "my_enum"
345+
required = True
346+
347+
schemas = Schemas()
348+
349+
prop, new_schemas = property_from_data(
350+
name=name, required=required, data=data, schemas=schemas, parent_name="parent", config=Config()
351+
)
352+
353+
assert prop == NoneProperty(
354+
name="my_enum", required=required, nullable=False, default="None", python_name="my_enum"
355+
)
356+
307357
def test_property_from_data_int_enum(self, enum_property_factory):
308-
from openapi_python_client.parser.properties import Class, EnumProperty, Schemas, property_from_data
358+
from openapi_python_client.parser.properties import Class, Schemas, property_from_data
309359
from openapi_python_client.schema import Schema
310360

311361
name = "my_enum"
@@ -923,14 +973,17 @@ def test_retries_failing_properties_while_making_progress(self, mocker):
923973
assert result.errors == [PropertyError()]
924974

925975

926-
def test_build_enum_property_conflict(mocker):
976+
def test_build_enum_property_conflict():
927977
from openapi_python_client.parser.properties import Schemas, build_enum_property
928978

929979
data = oai.Schema()
930-
schemas = Schemas(classes_by_name={"Existing": mocker.MagicMock()})
980+
schemas = Schemas()
931981

982+
_, schemas = build_enum_property(
983+
data=data, name="Existing", required=True, schemas=schemas, enum=["a"], parent_name=None, config=Config()
984+
)
932985
err, schemas = build_enum_property(
933-
data=data, name="Existing", required=True, schemas=schemas, enum=[], parent_name=None, config=Config()
986+
data=data, name="Existing", required=True, schemas=schemas, enum=["a", "b"], parent_name=None, config=Config()
934987
)
935988

936989
assert schemas == schemas

0 commit comments

Comments
 (0)