Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 2247d2f

Browse files
authoredJan 4, 2024
Handle bool enums better (#923)
TODO: - [x] Cover error cases with new unit tests --------- Co-authored-by: Dylan Anthony <[email protected]>
1 parent c10c1f2 commit 2247d2f

File tree

14 files changed

+282
-136
lines changed

14 files changed

+282
-136
lines changed
 
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
default: patch
3+
---
4+
5+
# Do not stop generation for invalid enum values
6+
7+
This generator only supports `enum` values that are strings or integers.
8+
Previously, this was handled at the parsing level, which would cause the generator to fail if there were any unsupported values in the document.
9+
Now, the generator will correctly keep going, skipping only endpoints which contained unsupported values.
10+
11+
Thanks for reporting #922 @macmoritz!
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
default: minor
3+
---
4+
5+
# Generate properties for some boolean enums
6+
7+
If a schema has both `type = "boolean"` and `enum` defined, a normal boolean property will now be created.
8+
Previously, the generator would error.
9+
10+
Note that the generate code _will not_ correctly limit the values to the enum values. To work around this, use the
11+
OpenAPI 3.1 `const` instead of `enum` to generate Python `Literal` types.
12+
13+
Thanks for reporting #922 @macmoritz!

‎end_to_end_tests/baseline_openapi_3.0.json

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@
6161
},
6262
"/bodies/json-like": {
6363
"post": {
64-
"tags": ["bodies"],
64+
"tags": [
65+
"bodies"
66+
],
6567
"description": "A content type that works like json but isn't application/json",
6668
"operationId": "json-like",
6769
"requestBody": {
@@ -799,11 +801,11 @@
799801
}
800802
}
801803
}
802-
},
803-
"/tests/int_enum": {
804+
},
805+
"/enum/int": {
804806
"post": {
805807
"tags": [
806-
"tests"
808+
"enums"
807809
],
808810
"summary": "Int Enum",
809811
"operationId": "int_enum_tests_int_enum_post",
@@ -825,14 +827,37 @@
825827
"schema": {}
826828
}
827829
}
828-
},
829-
"422": {
830-
"description": "Validation Error",
830+
}
831+
}
832+
}
833+
},
834+
"/enum/bool": {
835+
"post": {
836+
"tags": [
837+
"enums"
838+
],
839+
"summary": "Bool Enum",
840+
"operationId": "bool_enum_tests_bool_enum_post",
841+
"parameters": [
842+
{
843+
"required": true,
844+
"schema": {
845+
"type": "boolean",
846+
"enum": [
847+
true,
848+
false
849+
]
850+
},
851+
"name": "bool_enum",
852+
"in": "query"
853+
}
854+
],
855+
"responses": {
856+
"200": {
857+
"description": "Successful Response",
831858
"content": {
832859
"application/json": {
833-
"schema": {
834-
"$ref": "#/components/schemas/HTTPValidationError"
835-
}
860+
"schema": {}
836861
}
837862
}
838863
}

‎end_to_end_tests/baseline_openapi_3.1.yaml

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -794,10 +794,10 @@ info:
794794
}
795795
}
796796
},
797-
"/tests/int_enum": {
797+
"/enum/int": {
798798
"post": {
799799
"tags": [
800-
"tests"
800+
"enums"
801801
],
802802
"summary": "Int Enum",
803803
"operationId": "int_enum_tests_int_enum_post",
@@ -819,14 +819,37 @@ info:
819819
"schema": { }
820820
}
821821
}
822-
},
823-
"422": {
824-
"description": "Validation Error",
822+
}
823+
}
824+
}
825+
},
826+
"/enum/bool": {
827+
"post": {
828+
"tags": [
829+
"enums"
830+
],
831+
"summary": "Bool Enum",
832+
"operationId": "bool_enum_tests_bool_enum_post",
833+
"parameters": [
834+
{
835+
"required": true,
836+
"schema": {
837+
"type": "boolean",
838+
"enum": [
839+
true,
840+
false
841+
]
842+
},
843+
"name": "bool_enum",
844+
"in": "query"
845+
}
846+
],
847+
"responses": {
848+
"200": {
849+
"description": "Successful Response",
825850
"content": {
826851
"application/json": {
827-
"schema": {
828-
"$ref": "#/components/schemas/HTTPValidationError"
829-
}
852+
"schema": { }
830853
}
831854
}
832855
}

‎end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from .bodies import BodiesEndpoints
66
from .default import DefaultEndpoints
77
from .defaults import DefaultsEndpoints
8+
from .enums import EnumsEndpoints
89
from .location import LocationEndpoints
910
from .naming import NamingEndpoints
1011
from .parameter_references import ParameterReferencesEndpoints
@@ -28,6 +29,10 @@ def tests(cls) -> Type[TestsEndpoints]:
2829
def defaults(cls) -> Type[DefaultsEndpoints]:
2930
return DefaultsEndpoints
3031

32+
@classmethod
33+
def enums(cls) -> Type[EnumsEndpoints]:
34+
return EnumsEndpoints
35+
3136
@classmethod
3237
def responses(cls) -> Type[ResponsesEndpoints]:
3338
return ResponsesEndpoints
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
""" Contains methods for accessing the API Endpoints """
2+
3+
import types
4+
5+
from . import bool_enum_tests_bool_enum_post, int_enum_tests_int_enum_post
6+
7+
8+
class EnumsEndpoints:
9+
@classmethod
10+
def int_enum_tests_int_enum_post(cls) -> types.ModuleType:
11+
"""
12+
Int Enum
13+
"""
14+
return int_enum_tests_int_enum_post
15+
16+
@classmethod
17+
def bool_enum_tests_bool_enum_post(cls) -> types.ModuleType:
18+
"""
19+
Bool Enum
20+
"""
21+
return bool_enum_tests_bool_enum_post

‎end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/tests/__init__.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
get_basic_list_of_integers,
1111
get_basic_list_of_strings,
1212
get_user_list,
13-
int_enum_tests_int_enum_post,
1413
json_body_tests_json_body_post,
1514
no_response_tests_no_response_get,
1615
octet_stream_tests_octet_stream_get,
@@ -132,13 +131,6 @@ def unsupported_content_tests_unsupported_content_get(cls) -> types.ModuleType:
132131
"""
133132
return unsupported_content_tests_unsupported_content_get
134133

135-
@classmethod
136-
def int_enum_tests_int_enum_post(cls) -> types.ModuleType:
137-
"""
138-
Int Enum
139-
"""
140-
return int_enum_tests_int_enum_post
141-
142134
@classmethod
143135
def test_inline_objects(cls) -> types.ModuleType:
144136
"""

‎end_to_end_tests/golden-record/my_test_api_client/api/enums/__init__.py

Whitespace-only changes.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from http import HTTPStatus
2+
from typing import Any, Dict, Optional, Union
3+
4+
import httpx
5+
6+
from ... import errors
7+
from ...client import AuthenticatedClient, Client
8+
from ...types import UNSET, Response
9+
10+
11+
def _get_kwargs(
12+
*,
13+
bool_enum: bool,
14+
) -> Dict[str, Any]:
15+
params: Dict[str, Any] = {}
16+
17+
params["bool_enum"] = bool_enum
18+
19+
params = {k: v for k, v in params.items() if v is not UNSET and v is not None}
20+
21+
_kwargs: Dict[str, Any] = {
22+
"method": "post",
23+
"url": "/enum/bool",
24+
"params": params,
25+
}
26+
27+
return _kwargs
28+
29+
30+
def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[Any]:
31+
if response.status_code == HTTPStatus.OK:
32+
return None
33+
if client.raise_on_unexpected_status:
34+
raise errors.UnexpectedStatus(response.status_code, response.content)
35+
else:
36+
return None
37+
38+
39+
def _build_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Response[Any]:
40+
return Response(
41+
status_code=HTTPStatus(response.status_code),
42+
content=response.content,
43+
headers=response.headers,
44+
parsed=_parse_response(client=client, response=response),
45+
)
46+
47+
48+
def sync_detailed(
49+
*,
50+
client: Union[AuthenticatedClient, Client],
51+
bool_enum: bool,
52+
) -> Response[Any]:
53+
"""Bool Enum
54+
55+
Args:
56+
bool_enum (bool):
57+
58+
Raises:
59+
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
60+
httpx.TimeoutException: If the request takes longer than Client.timeout.
61+
62+
Returns:
63+
Response[Any]
64+
"""
65+
66+
kwargs = _get_kwargs(
67+
bool_enum=bool_enum,
68+
)
69+
70+
response = client.get_httpx_client().request(
71+
**kwargs,
72+
)
73+
74+
return _build_response(client=client, response=response)
75+
76+
77+
async def asyncio_detailed(
78+
*,
79+
client: Union[AuthenticatedClient, Client],
80+
bool_enum: bool,
81+
) -> Response[Any]:
82+
"""Bool Enum
83+
84+
Args:
85+
bool_enum (bool):
86+
87+
Raises:
88+
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
89+
httpx.TimeoutException: If the request takes longer than Client.timeout.
90+
91+
Returns:
92+
Response[Any]
93+
"""
94+
95+
kwargs = _get_kwargs(
96+
bool_enum=bool_enum,
97+
)
98+
99+
response = await client.get_async_httpx_client().request(**kwargs)
100+
101+
return _build_response(client=client, response=response)

‎end_to_end_tests/golden-record/my_test_api_client/api/tests/int_enum_tests_int_enum_post.py renamed to ‎end_to_end_tests/golden-record/my_test_api_client/api/enums/int_enum_tests_int_enum_post.py

Lines changed: 8 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from ... import errors
77
from ...client import AuthenticatedClient, Client
88
from ...models.an_int_enum import AnIntEnum
9-
from ...models.http_validation_error import HTTPValidationError
109
from ...types import UNSET, Response
1110

1211

@@ -23,32 +22,23 @@ def _get_kwargs(
2322

2423
_kwargs: Dict[str, Any] = {
2524
"method": "post",
26-
"url": "/tests/int_enum",
25+
"url": "/enum/int",
2726
"params": params,
2827
}
2928

3029
return _kwargs
3130

3231

33-
def _parse_response(
34-
*, client: Union[AuthenticatedClient, Client], response: httpx.Response
35-
) -> Optional[Union[Any, HTTPValidationError]]:
32+
def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[Any]:
3633
if response.status_code == HTTPStatus.OK:
37-
response_200 = response.json()
38-
return response_200
39-
if response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY:
40-
response_422 = HTTPValidationError.from_dict(response.json())
41-
42-
return response_422
34+
return None
4335
if client.raise_on_unexpected_status:
4436
raise errors.UnexpectedStatus(response.status_code, response.content)
4537
else:
4638
return None
4739

4840

49-
def _build_response(
50-
*, client: Union[AuthenticatedClient, Client], response: httpx.Response
51-
) -> Response[Union[Any, HTTPValidationError]]:
41+
def _build_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Response[Any]:
5242
return Response(
5343
status_code=HTTPStatus(response.status_code),
5444
content=response.content,
@@ -61,7 +51,7 @@ def sync_detailed(
6151
*,
6252
client: Union[AuthenticatedClient, Client],
6353
int_enum: AnIntEnum,
64-
) -> Response[Union[Any, HTTPValidationError]]:
54+
) -> Response[Any]:
6555
"""Int Enum
6656
6757
Args:
@@ -72,7 +62,7 @@ def sync_detailed(
7262
httpx.TimeoutException: If the request takes longer than Client.timeout.
7363
7464
Returns:
75-
Response[Union[Any, HTTPValidationError]]
65+
Response[Any]
7666
"""
7767

7868
kwargs = _get_kwargs(
@@ -86,35 +76,11 @@ def sync_detailed(
8676
return _build_response(client=client, response=response)
8777

8878

89-
def sync(
90-
*,
91-
client: Union[AuthenticatedClient, Client],
92-
int_enum: AnIntEnum,
93-
) -> Optional[Union[Any, HTTPValidationError]]:
94-
"""Int Enum
95-
96-
Args:
97-
int_enum (AnIntEnum): An enumeration.
98-
99-
Raises:
100-
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
101-
httpx.TimeoutException: If the request takes longer than Client.timeout.
102-
103-
Returns:
104-
Union[Any, HTTPValidationError]
105-
"""
106-
107-
return sync_detailed(
108-
client=client,
109-
int_enum=int_enum,
110-
).parsed
111-
112-
11379
async def asyncio_detailed(
11480
*,
11581
client: Union[AuthenticatedClient, Client],
11682
int_enum: AnIntEnum,
117-
) -> Response[Union[Any, HTTPValidationError]]:
83+
) -> Response[Any]:
11884
"""Int Enum
11985
12086
Args:
@@ -125,7 +91,7 @@ async def asyncio_detailed(
12591
httpx.TimeoutException: If the request takes longer than Client.timeout.
12692
12793
Returns:
128-
Response[Union[Any, HTTPValidationError]]
94+
Response[Any]
12995
"""
13096

13197
kwargs = _get_kwargs(
@@ -135,29 +101,3 @@ async def asyncio_detailed(
135101
response = await client.get_async_httpx_client().request(**kwargs)
136102

137103
return _build_response(client=client, response=response)
138-
139-
140-
async def asyncio(
141-
*,
142-
client: Union[AuthenticatedClient, Client],
143-
int_enum: AnIntEnum,
144-
) -> Optional[Union[Any, HTTPValidationError]]:
145-
"""Int Enum
146-
147-
Args:
148-
int_enum (AnIntEnum): An enumeration.
149-
150-
Raises:
151-
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
152-
httpx.TimeoutException: If the request takes longer than Client.timeout.
153-
154-
Returns:
155-
Union[Any, HTTPValidationError]
156-
"""
157-
158-
return (
159-
await asyncio_detailed(
160-
client=client,
161-
int_enum=int_enum,
162-
)
163-
).parsed

‎openapi_python_client/parser/properties/__init__.py

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -162,14 +162,24 @@ def property_from_data( # noqa: PLR0911
162162
config=config,
163163
roots=roots,
164164
)
165-
165+
if data.type == oai.DataType.BOOLEAN:
166+
return (
167+
BooleanProperty.build(
168+
name=name,
169+
required=required,
170+
default=data.default,
171+
python_name=utils.PythonIdentifier(value=name, prefix=config.field_prefix),
172+
description=data.description,
173+
example=data.example,
174+
),
175+
schemas,
176+
)
166177
if data.enum:
167178
return EnumProperty.build(
168179
data=data,
169180
name=name,
170181
required=required,
171182
schemas=schemas,
172-
enum=data.enum,
173183
parent_name=parent_name,
174184
config=config,
175185
)
@@ -223,18 +233,6 @@ def property_from_data( # noqa: PLR0911
223233
),
224234
schemas,
225235
)
226-
if data.type == oai.DataType.BOOLEAN:
227-
return (
228-
BooleanProperty.build(
229-
name=name,
230-
required=required,
231-
default=data.default,
232-
python_name=utils.PythonIdentifier(value=name, prefix=config.field_prefix),
233-
description=data.description,
234-
example=data.example,
235-
),
236-
schemas,
237-
)
238236
if data.type == oai.DataType.NULL:
239237
return (
240238
NoneProperty(

‎openapi_python_client/parser/properties/enum_property.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
__all__ = ["EnumProperty"]
44

5-
from typing import Any, ClassVar, Union, cast
5+
from typing import Any, ClassVar, List, Union, cast
66

77
from attr import evolve
88
from attrs import define
@@ -43,14 +43,13 @@ class EnumProperty(PropertyProtocol):
4343
}
4444

4545
@classmethod
46-
def build(
46+
def build( # noqa: PLR0911
4747
cls,
4848
*,
4949
data: oai.Schema,
5050
name: str,
5151
required: bool,
5252
schemas: Schemas,
53-
enum: list[str | None] | list[int | None],
5453
parent_name: str,
5554
config: Config,
5655
) -> tuple[EnumProperty | NoneProperty | UnionProperty | PropertyError, Schemas]:
@@ -70,16 +69,15 @@ def build(
7069
A tuple containing either the created property or a PropertyError AND update schemas.
7170
"""
7271

73-
if len(enum) == 0:
74-
return PropertyError(detail="No values provided for Enum", data=data), schemas
72+
enum = data.enum or [] # The outer function checks for this, but mypy doesn't know that
7573

7674
# OpenAPI allows for null as an enum value, but it doesn't make sense with how enums are constructed in Python.
7775
# So instead, if null is a possible value, make the property nullable.
7876
# Mypy is not smart enough to know that the type is right though
79-
value_list: list[str] | list[int] = [value for value in enum if value is not None] # type: ignore
77+
unchecked_value_list = [value for value in enum if value is not None] # type: ignore
8078

8179
# It's legal to have an enum that only contains null as a value, we don't bother constructing an enum for that
82-
if len(value_list) == 0:
80+
if len(unchecked_value_list) == 0:
8381
return (
8482
NoneProperty.build(
8583
name=name,
@@ -91,6 +89,19 @@ def build(
9189
),
9290
schemas,
9391
)
92+
93+
value_types = {type(value) for value in unchecked_value_list}
94+
if len(value_types) > 1:
95+
return PropertyError(
96+
header="Enum values must all be the same type", detail=f"Got {value_types}", data=data
97+
), schemas
98+
value_type = next(iter(value_types))
99+
if value_type not in (str, int):
100+
return PropertyError(header=f"Unsupported enum type {value_type}", data=data), schemas
101+
value_list = cast(
102+
Union[List[int], List[str]], unchecked_value_list
103+
) # We checked this with all the value_types stuff
104+
94105
if len(value_list) < len(enum): # Only one of the values was None, that becomes a union
95106
data.oneOf = [
96107
oai.Schema(type=DataType.NULL),
@@ -122,8 +133,6 @@ def build(
122133
schemas,
123134
)
124135

125-
value_type = type(next(iter(values.values())))
126-
127136
prop = EnumProperty(
128137
name=name,
129138
required=required,

‎openapi_python_client/schema/openapi_schema_pydantic/schema.py

Lines changed: 1 addition & 1 deletion
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_length=1)
38-
enum: Union[None, List[Optional[StrictInt]], List[Optional[StrictStr]]] = Field(default=None, min_length=1)
38+
enum: Union[None, List[Any]] = Field(default=None, min_length=1)
3939
const: Union[None, StrictStr, StrictInt] = None
4040
type: Union[DataType, List[DataType], None] = Field(default=None)
4141
allOf: List[Union[Reference, "Schema"]] = Field(default_factory=list)

‎tests/test_parser/test_properties/test_enum_property.py

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,57 +5,65 @@
55

66

77
def test_conflict():
8-
data = oai.Schema()
98
schemas = Schemas()
109

1110
_, schemas = EnumProperty.build(
12-
data=data, name="Existing", required=True, schemas=schemas, enum=["a"], parent_name="", config=Config()
11+
data=oai.Schema(enum=["a"]), name="Existing", required=True, schemas=schemas, parent_name="", config=Config()
1312
)
1413
err, new_schemas = EnumProperty.build(
15-
data=data,
14+
data=oai.Schema(enum=["a", "b"]),
1615
name="Existing",
1716
required=True,
1817
schemas=schemas,
19-
enum=["a", "b"],
2018
parent_name="",
2119
config=Config(),
2220
)
2321

2422
assert schemas == new_schemas
25-
assert err == PropertyError(detail="Found conflicting enums named Existing with incompatible values.", data=data)
23+
assert err.detail == "Found conflicting enums named Existing with incompatible values."
2624

2725

28-
def test_no_values():
29-
data = oai.Schema()
26+
def test_bad_default_value():
27+
data = oai.Schema(default="B", enum=["A"])
3028
schemas = Schemas()
3129

3230
err, new_schemas = EnumProperty.build(
33-
data=data, name="Existing", required=True, schemas=schemas, enum=[], parent_name=None, config=Config()
31+
data=data, name="Existing", required=True, schemas=schemas, parent_name="parent", config=Config()
3432
)
3533

3634
assert schemas == new_schemas
37-
assert err == PropertyError(detail="No values provided for Enum", data=data)
35+
assert err == PropertyError(detail="Value B is not valid for enum Existing", data=data)
3836

3937

40-
def test_bad_default_value():
41-
data = oai.Schema(default="B")
38+
def test_bad_default_type():
39+
data = oai.Schema(default=123, enum=["A"])
4240
schemas = Schemas()
4341

4442
err, new_schemas = EnumProperty.build(
45-
data=data, name="Existing", required=True, schemas=schemas, enum=["A"], parent_name=None, config=Config()
43+
data=data, name="Existing", required=True, schemas=schemas, parent_name="parent", config=Config()
4644
)
4745

4846
assert schemas == new_schemas
49-
assert err == PropertyError(detail="Value B is not valid for enum Existing", data=data)
47+
assert isinstance(err, PropertyError)
5048

5149

52-
def test_bad_default_type():
53-
data = oai.Schema(default=123)
50+
def test_mixed_types():
51+
data = oai.Schema(enum=["A", 1])
5452
schemas = Schemas()
5553

56-
err, new_schemas = EnumProperty.build(
57-
data=data, name="Existing", required=True, schemas=schemas, enum=["A"], parent_name=None, config=Config()
54+
err, _ = EnumProperty.build(
55+
data=data, name="Enum", required=True, schemas=schemas, parent_name="parent", config=Config()
56+
)
57+
58+
assert isinstance(err, PropertyError)
59+
60+
61+
def test_unsupported_type():
62+
data = oai.Schema(enum=[1.4, 1.5])
63+
schemas = Schemas()
64+
65+
err, _ = EnumProperty.build(
66+
data=data, name="Enum", required=True, schemas=schemas, parent_name="parent", config=Config()
5867
)
5968

60-
assert schemas == new_schemas
6169
assert isinstance(err, PropertyError)

0 commit comments

Comments
 (0)
Please sign in to comment.