Skip to content

Commit 70886ba

Browse files
juspencedbanty
authored andcommitted
Handle enum properties which have None / null as a possible value
For enums which only allow None, add new null_enum.py.jinja template For enums with mixed values, add logic to remove None from list Mark enum property as nullable if None isn't the only value Add tests for enums with mixed values and only null values TODO: Make the type checker stop failing, but PTO next week
1 parent 50374be commit 70886ba

File tree

9 files changed

+211
-0
lines changed

9 files changed

+211
-0
lines changed

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
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
10+
from ...models.an_enum_with_only_null import AnEnumWithOnlyNull
911
from ...models.http_validation_error import HTTPValidationError
1012
from ...types import UNSET, Response
1113

@@ -14,6 +16,8 @@ def _get_kwargs(
1416
*,
1517
client: Client,
1618
an_enum_value: List[AnEnum],
19+
an_enum_value_with_null: List[Optional[AnEnumWithNull]],
20+
an_enum_value_with_only_null: List[AnEnumWithOnlyNull],
1721
some_date: Union[datetime.date, datetime.datetime],
1822
) -> Dict[str, Any]:
1923
url = "{}/tests/".format(client.base_url)
@@ -27,13 +31,29 @@ def _get_kwargs(
2731

2832
json_an_enum_value.append(an_enum_value_item)
2933

34+
json_an_enum_value_with_null = []
35+
for an_enum_value_with_null_item_data in an_enum_value_with_null:
36+
an_enum_value_with_null_item = (
37+
an_enum_value_with_null_item_data.value if an_enum_value_with_null_item_data else None
38+
)
39+
40+
json_an_enum_value_with_null.append(an_enum_value_with_null_item)
41+
42+
json_an_enum_value_with_only_null = []
43+
for an_enum_value_with_only_null_item_data in an_enum_value_with_only_null:
44+
an_enum_value_with_only_null_item = an_enum_value_with_only_null_item_data.value
45+
46+
json_an_enum_value_with_only_null.append(an_enum_value_with_only_null_item)
47+
3048
if isinstance(some_date, datetime.date):
3149
json_some_date = some_date.isoformat()
3250
else:
3351
json_some_date = some_date.isoformat()
3452

3553
params: Dict[str, Any] = {
3654
"an_enum_value": json_an_enum_value,
55+
"an_enum_value_with_null": json_an_enum_value_with_null,
56+
"an_enum_value_with_only_null": json_an_enum_value_with_only_null,
3757
"some_date": json_some_date,
3858
}
3959
params = {k: v for k, v in params.items() if v is not UNSET and v is not None}
@@ -82,11 +102,15 @@ def sync_detailed(
82102
*,
83103
client: Client,
84104
an_enum_value: List[AnEnum],
105+
an_enum_value_with_null: List[Optional[AnEnumWithNull]],
106+
an_enum_value_with_only_null: List[AnEnumWithOnlyNull],
85107
some_date: Union[datetime.date, datetime.datetime],
86108
) -> Response[Union[HTTPValidationError, List[AModel]]]:
87109
kwargs = _get_kwargs(
88110
client=client,
89111
an_enum_value=an_enum_value,
112+
an_enum_value_with_null=an_enum_value_with_null,
113+
an_enum_value_with_only_null=an_enum_value_with_only_null,
90114
some_date=some_date,
91115
)
92116

@@ -101,13 +125,17 @@ def sync(
101125
*,
102126
client: Client,
103127
an_enum_value: List[AnEnum],
128+
an_enum_value_with_null: List[Optional[AnEnumWithNull]],
129+
an_enum_value_with_only_null: List[AnEnumWithOnlyNull],
104130
some_date: Union[datetime.date, datetime.datetime],
105131
) -> Optional[Union[HTTPValidationError, List[AModel]]]:
106132
"""Get a list of things"""
107133

108134
return sync_detailed(
109135
client=client,
110136
an_enum_value=an_enum_value,
137+
an_enum_value_with_null=an_enum_value_with_null,
138+
an_enum_value_with_only_null=an_enum_value_with_only_null,
111139
some_date=some_date,
112140
).parsed
113141

@@ -116,11 +144,15 @@ async def asyncio_detailed(
116144
*,
117145
client: Client,
118146
an_enum_value: List[AnEnum],
147+
an_enum_value_with_null: List[Optional[AnEnumWithNull]],
148+
an_enum_value_with_only_null: List[AnEnumWithOnlyNull],
119149
some_date: Union[datetime.date, datetime.datetime],
120150
) -> Response[Union[HTTPValidationError, List[AModel]]]:
121151
kwargs = _get_kwargs(
122152
client=client,
123153
an_enum_value=an_enum_value,
154+
an_enum_value_with_null=an_enum_value_with_null,
155+
an_enum_value_with_only_null=an_enum_value_with_only_null,
124156
some_date=some_date,
125157
)
126158

@@ -134,6 +166,8 @@ async def asyncio(
134166
*,
135167
client: Client,
136168
an_enum_value: List[AnEnum],
169+
an_enum_value_with_null: List[Optional[AnEnumWithNull]],
170+
an_enum_value_with_only_null: List[AnEnumWithOnlyNull],
137171
some_date: Union[datetime.date, datetime.datetime],
138172
) -> Optional[Union[HTTPValidationError, List[AModel]]]:
139173
"""Get a list of things"""
@@ -142,6 +176,8 @@ async def asyncio(
142176
await asyncio_detailed(
143177
client=client,
144178
an_enum_value=an_enum_value,
179+
an_enum_value_with_null=an_enum_value_with_null,
180+
an_enum_value_with_only_null=an_enum_value_with_only_null,
145181
some_date=some_date,
146182
)
147183
).parsed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
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
11+
from .an_enum_with_only_null import AnEnumWithOnlyNull
1012
from .an_int_enum import AnIntEnum
1113
from .another_all_of_sub_model import AnotherAllOfSubModel
1214
from .another_all_of_sub_model_type import AnotherAllOfSubModelType
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
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)
10+
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from enum import Enum
2+
3+
4+
class AnEnumWithOnlyNull(Enum):
5+
VALUE_0 = None
6+
7+
def __str__(self) -> str:
8+
return str(self.value)

end_to_end_tests/openapi.json

Lines changed: 40 additions & 0 deletions
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/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ def _build_models(self) -> None:
227227
models_dir.mkdir()
228228
models_init = models_dir / "__init__.py"
229229
imports = []
230+
NoneType = type(None)
230231

231232
model_template = self.env.get_template("model.py.jinja")
232233
for model in self.openapi.models:
@@ -236,11 +237,14 @@ def _build_models(self) -> None:
236237

237238
# Generate enums
238239
str_enum_template = self.env.get_template("str_enum.py.jinja")
240+
null_enum_template = self.env.get_template("null_enum.py.jinja")
239241
int_enum_template = self.env.get_template("int_enum.py.jinja")
240242
for enum in self.openapi.enums:
241243
module_path = models_dir / f"{enum.class_info.module_name}.py"
242244
if enum.value_type is int:
243245
module_path.write_text(int_enum_template.render(enum=enum), encoding=self.file_encoding)
246+
elif enum.value_type is NoneType:
247+
module_path.write_text(null_enum_template.render(enum=enum), encoding=self.file_encoding)
244248
else:
245249
module_path.write_text(str_enum_template.render(enum=enum), encoding=self.file_encoding)
246250
imports.append(import_string_from_class(enum.class_info))

openapi_python_client/parser/properties/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,14 @@ def build_enum_property(
332332
schemas,
333333
)
334334

335+
# Remove None from str / int list, if present, and mark property as nullable
336+
# If list only has None, with no str or int, make special None enum instead
337+
keys_to_remove = [key for key, value in values.items() if value is None]
338+
if keys_to_remove and len(keys_to_remove) < len(values.items()):
339+
data.nullable = True
340+
for key in keys_to_remove:
341+
values.pop(key)
342+
335343
for value in values.values():
336344
value_type = type(value)
337345
break
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from enum import Enum
2+
3+
class {{ enum.class_info.name }}(Enum):
4+
{% for key, value in enum.values.items() %}
5+
{{ key }} = {{ value }}
6+
{% endfor %}
7+
8+
def __str__(self) -> str:
9+
return str(self.value)

tests/test_parser/test_properties/test_init.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,8 @@ class TestEnumProperty:
222222
(True, False, "{}"),
223223
(False, True, "Union[Unset, None, {}]"),
224224
(True, True, "Optional[{}]"),
225+
(True, False, "Optional[{}]"),
226+
(True, False, "{}"),
225227
),
226228
)
227229
def test_get_type_string(self, mocker, enum_property_factory, required, nullable, expected):
@@ -273,6 +275,37 @@ def test_values_from_list_duplicate(self):
273275
with pytest.raises(ValueError):
274276
EnumProperty.values_from_list(data)
275277

278+
def test_values_from_list_with_null(self):
279+
from openapi_python_client.parser.properties import EnumProperty
280+
281+
data = ["abc", "123", "a23", "1bc", 4, -3, "a Thing WIth spaces", "", "null"]
282+
283+
result = EnumProperty.values_from_list(data)
284+
285+
# None / null is removed from result, and result is now Optional[{}]
286+
assert result == {
287+
"ABC": "abc",
288+
"VALUE_1": "123",
289+
"A23": "a23",
290+
"VALUE_3": "1bc",
291+
"VALUE_4": 4,
292+
"VALUE_NEGATIVE_3": -3,
293+
"A_THING_WITH_SPACES": "a Thing WIth spaces",
294+
"VALUE_7": "",
295+
}
296+
297+
def test_values_from_list_with_only_null(self):
298+
from openapi_python_client.parser.properties import EnumProperty
299+
300+
data = ["null"]
301+
302+
result = EnumProperty.values_from_list(data)
303+
304+
# None / null is not removed from result since it's the only value
305+
assert result == {
306+
"VALUE_0": None,
307+
}
308+
276309

277310
class TestPropertyFromData:
278311
def test_property_from_data_str_enum(self, enum_property_factory):
@@ -304,6 +337,67 @@ def test_property_from_data_str_enum(self, enum_property_factory):
304337
"ParentAnEnum": prop,
305338
}
306339

340+
def test_property_from_data_str_enum_with_null(self, enum_property_factory):
341+
from openapi_python_client.parser.properties import Class, Schemas, property_from_data
342+
from openapi_python_client.schema import Schema
343+
344+
existing = enum_property_factory()
345+
data = Schema(title="AnEnumWithNull", enum=["A", "B", "C", "null"], nullable=False, default="B")
346+
name = "my_enum"
347+
required = True
348+
349+
schemas = Schemas(classes_by_name={"AnEnum": existing})
350+
351+
prop, new_schemas = property_from_data(
352+
name=name, required=required, data=data, schemas=schemas, parent_name="parent", config=Config()
353+
)
354+
355+
# None / null is removed from enum, and property is now nullable
356+
assert prop == enum_property_factory(
357+
name=name,
358+
required=required,
359+
values={"A": "A", "B": "B", "C": "C"},
360+
class_info=Class(name="ParentAnEnum", module_name="parent_an_enum"),
361+
value_type=str,
362+
default="ParentAnEnum.B",
363+
)
364+
assert prop.nullable is True
365+
assert schemas != new_schemas, "Provided Schemas was mutated"
366+
assert new_schemas.classes_by_name == {
367+
"AnEnumWithNull": existing,
368+
"ParentAnEnum": prop,
369+
}
370+
371+
def test_property_from_data_null_enum(self, enum_property_factory):
372+
from openapi_python_client.parser.properties import Class, Schemas, property_from_data
373+
from openapi_python_client.schema import Schema
374+
375+
existing = enum_property_factory()
376+
data = Schema(title="AnEnumWithOnlyNull", enum=["null"], nullable=False, default=None)
377+
name = "my_enum"
378+
required = True
379+
380+
schemas = Schemas(classes_by_name={"AnEnum": existing})
381+
382+
prop, new_schemas = property_from_data(
383+
name=name, required=required, data=data, schemas=schemas, parent_name="parent", config=Config()
384+
)
385+
386+
assert prop == enum_property_factory(
387+
name=name,
388+
required=required,
389+
values={"VALUE_0": None},
390+
class_info=Class(name="ParentAnEnum", module_name="parent_an_enum"),
391+
value_type=type(None),
392+
default=None,
393+
)
394+
assert prop.nullable is False
395+
assert schemas != new_schemas, "Provided Schemas was mutated"
396+
assert new_schemas.classes_by_name == {
397+
"AnEnumWithOnlyNull": existing,
398+
"ParentAnEnum": prop,
399+
}
400+
307401
def test_property_from_data_int_enum(self, enum_property_factory):
308402
from openapi_python_client.parser.properties import Class, EnumProperty, Schemas, property_from_data
309403
from openapi_python_client.schema import Schema

0 commit comments

Comments
 (0)