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 cf181c2

Browse files
eli-bldbanty
andauthoredAug 25, 2024··
correctly resolve references to a type that is itself just a single allOf reference (#1103)
Fixes #1091 Example of a valid spec that triggered this bug: https://gist.github.com/eli-bl/8f5c7d1d872d9fda5379fa6370dab6a8 In this spec, CreateCat contains only an `allOf` with a single reference to CreateAnimal. The current behavior of `openapi-python-client` in such a case is that it treats CreateCat as simply an alias for CreateAnimal; any references to it are treated as references to CreateAnimal, and it doesn't bother creating a model class for CreateCat. And if the spec contained only those two types, then this would be successful. (Note, the term "alias" doesn't exist in OpenAPI/JSON Schema, but I'm using it here to mean "a type that extends one other type with `allOf`, with no changes." Whether that should be treated as a separate thing in any way is not really a concern of OpenAPI; it's an issue for us only because we are generating code for model classes. See also: #1104) Anyway, in this case the spec also contains UpdateCat, which extends CreateCat with an additional property. This _should_ be exactly the same as extending CreateAnimal... but, prior to this fix, it resulted in a parsing error. The problem happened like this: 1. In `_create_schemas`, we create a ModelProperty for each of the three schemas. * The one for CreateCat is handled slightly differently: its `data` attribute points to the exact same schema as CreateAnimal, and we do not add it into `schemas.classes_by_name` because we don't want to generate a separate Python class for it. 2. In `_process_models`, we're attempting to finish filling in the property list for each model. * That might not be possible right away because there might be a reference to another model that hasn't been fully processed yet. So we iterate as many times as necessary until they're all fully resolved. However... * What we are iterating over is `schemas.classes_by_name`. There's an incorrect assumption that every named model is included in that dict; in this case, CreateCat is not in it. * Therefore, CreateCat remains in an invalid state, and the reference from CreateAnimal to CreateCat causes an error. My solution is to use `classes_by_name` only for the purpose of determining what Python classes to generate, and add a new collection, `models_to_process`, which includes _every_ ModelProperty including ones that are aliases. After the fix, generating a client from the example spec succeeds. The only Python model classes created are CreateAnimal and UpdateCat; the `post` endpoint that referenced CreateCat uses the CreateAnimal class. Again, that's consistent with how `openapi-python-client` currently handles these type aliases; the difference is just that it no longer fails when it sees a reference _to_ the alias. --------- Co-authored-by: Dylan Anthony <[email protected]>
1 parent 5f7c9a5 commit cf181c2

File tree

14 files changed

+932
-59
lines changed

14 files changed

+932
-59
lines changed
 
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
default: patch
3+
---
4+
5+
# Correctly resolve references to a type that is itself just a single allOf reference
6+
7+
PR #1103 fixed issue #1091. Thanks @eli-bl!

‎end_to_end_tests/baseline_openapi_3.0.json

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1629,6 +1629,33 @@
16291629
}
16301630
}
16311631
}
1632+
},
1633+
"/models/allof": {
1634+
"get": {
1635+
"responses": {
1636+
"200": {
1637+
"description": "OK",
1638+
"content": {
1639+
"application/json": {
1640+
"schema": {
1641+
"type": "object",
1642+
"properties": {
1643+
"aliased": {
1644+
"$ref": "#/components/schemas/Aliased"
1645+
},
1646+
"extended": {
1647+
"$ref": "#/components/schemas/Extended"
1648+
},
1649+
"model": {
1650+
"$ref": "#/components/schemas/AModel"
1651+
}
1652+
}
1653+
}
1654+
}
1655+
}
1656+
}
1657+
}
1658+
}
16321659
}
16331660
},
16341661
"components": {
@@ -1647,6 +1674,23 @@
16471674
"an_required_field"
16481675
]
16491676
},
1677+
"Aliased":{
1678+
"allOf": [
1679+
{"$ref": "#/components/schemas/AModel"}
1680+
]
1681+
},
1682+
"Extended": {
1683+
"allOf": [
1684+
{"$ref": "#/components/schemas/Aliased"},
1685+
{"type": "object",
1686+
"properties": {
1687+
"fromExtended": {
1688+
"type": "string"
1689+
}
1690+
}
1691+
}
1692+
]
1693+
},
16501694
"AModel": {
16511695
"title": "AModel",
16521696
"required": [

‎end_to_end_tests/baseline_openapi_3.1.yaml

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1619,7 +1619,34 @@ info:
16191619
}
16201620
}
16211621
}
1622-
}
1622+
},
1623+
"/models/allof": {
1624+
"get": {
1625+
"responses": {
1626+
"200": {
1627+
"description": "OK",
1628+
"content": {
1629+
"application/json": {
1630+
"schema": {
1631+
"type": "object",
1632+
"properties": {
1633+
"aliased": {
1634+
"$ref": "#/components/schemas/Aliased"
1635+
},
1636+
"extended": {
1637+
"$ref": "#/components/schemas/Extended"
1638+
},
1639+
"model": {
1640+
"$ref": "#/components/schemas/AModel"
1641+
}
1642+
}
1643+
}
1644+
}
1645+
}
1646+
}
1647+
}
1648+
}
1649+
},
16231650
}
16241651
"components":
16251652
"schemas": {
@@ -1637,6 +1664,23 @@ info:
16371664
"an_required_field"
16381665
]
16391666
},
1667+
"Aliased": {
1668+
"allOf": [
1669+
{ "$ref": "#/components/schemas/AModel" }
1670+
]
1671+
},
1672+
"Extended": {
1673+
"allOf": [
1674+
{ "$ref": "#/components/schemas/Aliased" },
1675+
{ "type": "object",
1676+
"properties": {
1677+
"fromExtended": {
1678+
"type": "string"
1679+
}
1680+
}
1681+
}
1682+
]
1683+
},
16401684
"AModel": {
16411685
"title": "AModel",
16421686
"required": [
@@ -1667,11 +1711,7 @@ info:
16671711
"default": "overridden_default"
16681712
},
16691713
"an_optional_allof_enum": {
1670-
"allOf": [
1671-
{
1672-
"$ref": "#/components/schemas/AnAllOfEnum"
1673-
}
1674-
]
1714+
"$ref": "#/components/schemas/AnAllOfEnum",
16751715
},
16761716
"nested_list_of_enums": {
16771717
"title": "Nested List Of Enums",
@@ -1808,11 +1848,7 @@ info:
18081848
]
18091849
},
18101850
"model": {
1811-
"allOf": [
1812-
{
1813-
"$ref": "#/components/schemas/ModelWithUnionProperty"
1814-
}
1815-
]
1851+
"$ref": "#/components/schemas/ModelWithUnionProperty"
18161852
},
18171853
"nullable_model": {
18181854
"oneOf": [
@@ -1825,11 +1861,7 @@ info:
18251861
]
18261862
},
18271863
"not_required_model": {
1828-
"allOf": [
1829-
{
1830-
"$ref": "#/components/schemas/ModelWithUnionProperty"
1831-
}
1832-
]
1864+
"$ref": "#/components/schemas/ModelWithUnionProperty"
18331865
},
18341866
"not_required_nullable_model": {
18351867
"oneOf": [

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import types
44

5-
from . import get_common_parameters, post_common_parameters, reserved_parameters
5+
from . import get_common_parameters, get_models_allof, post_common_parameters, reserved_parameters
66

77

88
class DefaultEndpoints:
@@ -17,3 +17,7 @@ def post_common_parameters(cls) -> types.ModuleType:
1717
@classmethod
1818
def reserved_parameters(cls) -> types.ModuleType:
1919
return reserved_parameters
20+
21+
@classmethod
22+
def get_models_allof(cls) -> types.ModuleType:
23+
return get_models_allof
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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 ...models.get_models_allof_response_200 import GetModelsAllofResponse200
9+
from ...types import Response
10+
11+
12+
def _get_kwargs() -> Dict[str, Any]:
13+
_kwargs: Dict[str, Any] = {
14+
"method": "get",
15+
"url": "/models/allof",
16+
}
17+
18+
return _kwargs
19+
20+
21+
def _parse_response(
22+
*, client: Union[AuthenticatedClient, Client], response: httpx.Response
23+
) -> Optional[GetModelsAllofResponse200]:
24+
if response.status_code == HTTPStatus.OK:
25+
response_200 = GetModelsAllofResponse200.from_dict(response.json())
26+
27+
return response_200
28+
if client.raise_on_unexpected_status:
29+
raise errors.UnexpectedStatus(response.status_code, response.content)
30+
else:
31+
return None
32+
33+
34+
def _build_response(
35+
*, client: Union[AuthenticatedClient, Client], response: httpx.Response
36+
) -> Response[GetModelsAllofResponse200]:
37+
return Response(
38+
status_code=HTTPStatus(response.status_code),
39+
content=response.content,
40+
headers=response.headers,
41+
parsed=_parse_response(client=client, response=response),
42+
)
43+
44+
45+
def sync_detailed(
46+
*,
47+
client: Union[AuthenticatedClient, Client],
48+
) -> Response[GetModelsAllofResponse200]:
49+
"""
50+
Raises:
51+
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
52+
httpx.TimeoutException: If the request takes longer than Client.timeout.
53+
54+
Returns:
55+
Response[GetModelsAllofResponse200]
56+
"""
57+
58+
kwargs = _get_kwargs()
59+
60+
response = client.get_httpx_client().request(
61+
**kwargs,
62+
)
63+
64+
return _build_response(client=client, response=response)
65+
66+
67+
def sync(
68+
*,
69+
client: Union[AuthenticatedClient, Client],
70+
) -> Optional[GetModelsAllofResponse200]:
71+
"""
72+
Raises:
73+
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
74+
httpx.TimeoutException: If the request takes longer than Client.timeout.
75+
76+
Returns:
77+
GetModelsAllofResponse200
78+
"""
79+
80+
return sync_detailed(
81+
client=client,
82+
).parsed
83+
84+
85+
async def asyncio_detailed(
86+
*,
87+
client: Union[AuthenticatedClient, Client],
88+
) -> Response[GetModelsAllofResponse200]:
89+
"""
90+
Raises:
91+
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
92+
httpx.TimeoutException: If the request takes longer than Client.timeout.
93+
94+
Returns:
95+
Response[GetModelsAllofResponse200]
96+
"""
97+
98+
kwargs = _get_kwargs()
99+
100+
response = await client.get_async_httpx_client().request(**kwargs)
101+
102+
return _build_response(client=client, response=response)
103+
104+
105+
async def asyncio(
106+
*,
107+
client: Union[AuthenticatedClient, Client],
108+
) -> Optional[GetModelsAllofResponse200]:
109+
"""
110+
Raises:
111+
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
112+
httpx.TimeoutException: If the request takes longer than Client.timeout.
113+
114+
Returns:
115+
GetModelsAllofResponse200
116+
"""
117+
118+
return (
119+
await asyncio_detailed(
120+
client=client,
121+
)
122+
).parsed

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,11 @@
3434
from .body_upload_file_tests_upload_post_some_object import BodyUploadFileTestsUploadPostSomeObject
3535
from .body_upload_file_tests_upload_post_some_optional_object import BodyUploadFileTestsUploadPostSomeOptionalObject
3636
from .different_enum import DifferentEnum
37+
from .extended import Extended
3738
from .free_form_model import FreeFormModel
3839
from .get_location_header_types_int_enum_header import GetLocationHeaderTypesIntEnumHeader
3940
from .get_location_header_types_string_enum_header import GetLocationHeaderTypesStringEnumHeader
41+
from .get_models_allof_response_200 import GetModelsAllofResponse200
4042
from .http_validation_error import HTTPValidationError
4143
from .import_ import Import
4244
from .json_like_body import JsonLikeBody
@@ -111,9 +113,11 @@
111113
"BodyUploadFileTestsUploadPostSomeObject",
112114
"BodyUploadFileTestsUploadPostSomeOptionalObject",
113115
"DifferentEnum",
116+
"Extended",
114117
"FreeFormModel",
115118
"GetLocationHeaderTypesIntEnumHeader",
116119
"GetLocationHeaderTypesStringEnumHeader",
120+
"GetModelsAllofResponse200",
117121
"HTTPValidationError",
118122
"Import",
119123
"JsonLikeBody",

‎end_to_end_tests/golden-record/my_test_api_client/models/extended.py

Lines changed: 514 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
from typing import TYPE_CHECKING, Any, Dict, List, Type, TypeVar, Union
2+
3+
from attrs import define as _attrs_define
4+
from attrs import field as _attrs_field
5+
6+
from ..types import UNSET, Unset
7+
8+
if TYPE_CHECKING:
9+
from ..models.a_model import AModel
10+
from ..models.extended import Extended
11+
12+
13+
T = TypeVar("T", bound="GetModelsAllofResponse200")
14+
15+
16+
@_attrs_define
17+
class GetModelsAllofResponse200:
18+
"""
19+
Attributes:
20+
aliased (Union[Unset, AModel]): A Model for testing all the ways custom objects can be used
21+
extended (Union[Unset, Extended]):
22+
model (Union[Unset, AModel]): A Model for testing all the ways custom objects can be used
23+
"""
24+
25+
aliased: Union[Unset, "AModel"] = UNSET
26+
extended: Union[Unset, "Extended"] = UNSET
27+
model: Union[Unset, "AModel"] = UNSET
28+
additional_properties: Dict[str, Any] = _attrs_field(init=False, factory=dict)
29+
30+
def to_dict(self) -> Dict[str, Any]:
31+
aliased: Union[Unset, Dict[str, Any]] = UNSET
32+
if not isinstance(self.aliased, Unset):
33+
aliased = self.aliased.to_dict()
34+
35+
extended: Union[Unset, Dict[str, Any]] = UNSET
36+
if not isinstance(self.extended, Unset):
37+
extended = self.extended.to_dict()
38+
39+
model: Union[Unset, Dict[str, Any]] = UNSET
40+
if not isinstance(self.model, Unset):
41+
model = self.model.to_dict()
42+
43+
field_dict: Dict[str, Any] = {}
44+
field_dict.update(self.additional_properties)
45+
field_dict.update({})
46+
if aliased is not UNSET:
47+
field_dict["aliased"] = aliased
48+
if extended is not UNSET:
49+
field_dict["extended"] = extended
50+
if model is not UNSET:
51+
field_dict["model"] = model
52+
53+
return field_dict
54+
55+
@classmethod
56+
def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T:
57+
from ..models.a_model import AModel
58+
from ..models.extended import Extended
59+
60+
d = src_dict.copy()
61+
_aliased = d.pop("aliased", UNSET)
62+
aliased: Union[Unset, AModel]
63+
if isinstance(_aliased, Unset):
64+
aliased = UNSET
65+
else:
66+
aliased = AModel.from_dict(_aliased)
67+
68+
_extended = d.pop("extended", UNSET)
69+
extended: Union[Unset, Extended]
70+
if isinstance(_extended, Unset):
71+
extended = UNSET
72+
else:
73+
extended = Extended.from_dict(_extended)
74+
75+
_model = d.pop("model", UNSET)
76+
model: Union[Unset, AModel]
77+
if isinstance(_model, Unset):
78+
model = UNSET
79+
else:
80+
model = AModel.from_dict(_model)
81+
82+
get_models_allof_response_200 = cls(
83+
aliased=aliased,
84+
extended=extended,
85+
model=model,
86+
)
87+
88+
get_models_allof_response_200.additional_properties = d
89+
return get_models_allof_response_200
90+
91+
@property
92+
def additional_keys(self) -> List[str]:
93+
return list(self.additional_properties.keys())
94+
95+
def __getitem__(self, key: str) -> Any:
96+
return self.additional_properties[key]
97+
98+
def __setitem__(self, key: str, value: Any) -> None:
99+
self.additional_properties[key] = value
100+
101+
def __delitem__(self, key: str) -> None:
102+
del self.additional_properties[key]
103+
104+
def __contains__(self, key: str) -> bool:
105+
return key in self.additional_properties

‎openapi_python_client/parser/bodies.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ def body_from_data(
117117
**schemas.classes_by_name,
118118
prop.class_info.name: prop,
119119
},
120+
models_to_process=[*schemas.models_to_process, prop],
120121
)
121122
bodies.append(
122123
Body(

‎openapi_python_client/parser/properties/__init__.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ def _property_from_ref(
126126
return prop, schemas
127127

128128

129-
def property_from_data( # noqa: PLR0911
129+
def property_from_data( # noqa: PLR0911, PLR0912
130130
name: str,
131131
required: bool,
132132
data: oai.Reference | oai.Schema,
@@ -153,7 +153,7 @@ def property_from_data( # noqa: PLR0911
153153
sub_data: list[oai.Schema | oai.Reference] = data.allOf + data.anyOf + data.oneOf
154154
# A union of a single reference should just be passed through to that reference (don't create copy class)
155155
if len(sub_data) == 1 and isinstance(sub_data[0], oai.Reference):
156-
return _property_from_ref(
156+
prop, schemas = _property_from_ref(
157157
name=name,
158158
required=required,
159159
parent=data,
@@ -162,6 +162,16 @@ def property_from_data( # noqa: PLR0911
162162
config=config,
163163
roots=roots,
164164
)
165+
# We won't be generating a separate Python class for this schema - references to it will just use
166+
# the class for the schema it's referencing - so we don't add it to classes_by_name; but we do
167+
# add it to models_to_process, if it's a model, because its properties still need to be resolved.
168+
if isinstance(prop, ModelProperty):
169+
schemas = evolve(
170+
schemas,
171+
models_to_process=[*schemas.models_to_process, prop],
172+
)
173+
return prop, schemas
174+
165175
if data.type == oai.DataType.BOOLEAN:
166176
return (
167177
BooleanProperty.build(
@@ -341,7 +351,7 @@ def _process_model_errors(
341351

342352

343353
def _process_models(*, schemas: Schemas, config: Config) -> Schemas:
344-
to_process = (prop for prop in schemas.classes_by_name.values() if isinstance(prop, ModelProperty))
354+
to_process = schemas.models_to_process
345355
still_making_progress = True
346356
final_model_errors: list[tuple[ModelProperty, PropertyError]] = []
347357
latest_model_errors: list[tuple[ModelProperty, PropertyError]] = []
@@ -368,12 +378,11 @@ def _process_models(*, schemas: Schemas, config: Config) -> Schemas:
368378
continue
369379
schemas = schemas_or_err
370380
still_making_progress = True
371-
to_process = (prop for prop in next_round)
381+
to_process = next_round
372382

373383
final_model_errors.extend(latest_model_errors)
374384
errors = _process_model_errors(final_model_errors, schemas=schemas)
375-
schemas.errors.extend(errors)
376-
return schemas
385+
return evolve(schemas, errors=[*schemas.errors, *errors], models_to_process=to_process)
377386

378387

379388
def build_schemas(

‎openapi_python_client/parser/properties/model_property.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,11 @@ def build(
119119
)
120120
return error, schemas
121121

122-
schemas = evolve(schemas, classes_by_name={**schemas.classes_by_name, class_info.name: prop})
122+
schemas = evolve(
123+
schemas,
124+
classes_by_name={**schemas.classes_by_name, class_info.name: prop},
125+
models_to_process=[*schemas.models_to_process, prop],
126+
)
123127
return prop, schemas
124128

125129
@classmethod

‎openapi_python_client/parser/properties/schemas.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222
from ..errors import ParameterError, ParseError, PropertyError
2323

2424
if TYPE_CHECKING: # pragma: no cover
25+
from .model_property import ModelProperty
2526
from .property import Property
2627
else:
28+
ModelProperty = "ModelProperty"
2729
Property = "Property"
2830

2931

@@ -77,6 +79,7 @@ class Schemas:
7779
classes_by_reference: Dict[ReferencePath, Property] = field(factory=dict)
7880
dependencies: Dict[ReferencePath, Set[Union[ReferencePath, ClassName]]] = field(factory=dict)
7981
classes_by_name: Dict[ClassName, Property] = field(factory=dict)
82+
models_to_process: List[ModelProperty] = field(factory=list)
8083
errors: List[ParseError] = field(factory=list)
8184

8285
def add_dependencies(self, ref_path: ReferencePath, roots: Set[Union[ReferencePath, ClassName]]) -> None:

‎pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ composite = ["test --cov openapi_python_client tests --cov-report=term-missing"]
129129

130130
[tool.pdm.scripts.regen_integration]
131131
shell = """
132-
openapi-python-client update --url https://raw.githubusercontent.com/openapi-generators/openapi-test-server/main/openapi.json --config integration-tests/config.yaml --meta pdm \
132+
openapi-python-client generate --overwrite --url https://raw.githubusercontent.com/openapi-generators/openapi-test-server/main/openapi.json --config integration-tests/config.yaml --meta none --output-path integration-tests/integration_tests \
133133
"""
134134

135135
[build-system]

‎tests/test_parser/test_properties/test_init.py

Lines changed: 58 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,7 @@ def test_property_from_data_ref_enum_with_overridden_default(self, enum_property
530530
prop, new_schemas = property_from_data(
531531
name=name, required=required, data=data, schemas=schemas, parent_name="", config=config
532532
)
533+
new_schemas = attr.evolve(new_schemas, models_to_process=[]) # intermediate state irrelevant to this test
533534

534535
assert prop == enum_property_factory(
535536
name="some_enum",
@@ -911,37 +912,6 @@ def test_retries_failing_properties_while_making_progress(self, mocker, config):
911912

912913

913914
class TestProcessModels:
914-
def test_retries_failing_models_while_making_progress(
915-
self, mocker, model_property_factory, any_property_factory, config
916-
):
917-
from openapi_python_client.parser.properties import _process_models
918-
919-
first_model = model_property_factory()
920-
second_class_name = ClassName("second", "")
921-
schemas = Schemas(
922-
classes_by_name={
923-
ClassName("first", ""): first_model,
924-
second_class_name: model_property_factory(),
925-
ClassName("non-model", ""): any_property_factory(),
926-
}
927-
)
928-
process_model = mocker.patch(
929-
f"{MODULE_NAME}.process_model", side_effect=[PropertyError(), Schemas(), PropertyError()]
930-
)
931-
process_model_errors = mocker.patch(f"{MODULE_NAME}._process_model_errors", return_value=["error"])
932-
933-
result = _process_models(schemas=schemas, config=config)
934-
935-
process_model.assert_has_calls(
936-
[
937-
call(first_model, schemas=schemas, config=config),
938-
call(schemas.classes_by_name[second_class_name], schemas=schemas, config=config),
939-
call(first_model, schemas=result, config=config),
940-
]
941-
)
942-
assert process_model_errors.was_called_once_with([(first_model, PropertyError())])
943-
assert all(error in result.errors for error in process_model_errors.return_value)
944-
945915
def test_detect_recursive_allof_reference_no_retry(self, mocker, model_property_factory, config):
946916
from openapi_python_client.parser.properties import Class, _process_models
947917
from openapi_python_client.schema import Reference
@@ -950,14 +920,16 @@ def test_detect_recursive_allof_reference_no_retry(self, mocker, model_property_
950920
recursive_model = model_property_factory(
951921
class_info=Class(name=class_name, module_name=PythonIdentifier("module_name", ""))
952922
)
923+
second_model = model_property_factory()
953924
schemas = Schemas(
954925
classes_by_name={
955926
"recursive": recursive_model,
956-
"second": model_property_factory(),
957-
}
927+
"second": second_model,
928+
},
929+
models_to_process=[recursive_model, second_model],
958930
)
959931
recursion_error = PropertyError(data=Reference.model_construct(ref=f"#/{class_name}"))
960-
process_model = mocker.patch(f"{MODULE_NAME}.process_model", side_effect=[recursion_error, Schemas()])
932+
process_model = mocker.patch(f"{MODULE_NAME}.process_model", side_effect=[recursion_error, schemas])
961933
process_model_errors = mocker.patch(f"{MODULE_NAME}._process_model_errors", return_value=["error"])
962934

963935
result = _process_models(schemas=schemas, config=config)
@@ -972,6 +944,58 @@ def test_detect_recursive_allof_reference_no_retry(self, mocker, model_property_
972944
assert all(error in result.errors for error in process_model_errors.return_value)
973945
assert "\n\nRecursive allOf reference found" in recursion_error.detail
974946

947+
def test_resolve_reference_to_single_allof_reference(self, config, model_property_factory):
948+
# test for https://github.com/openapi-generators/openapi-python-client/issues/1091
949+
from openapi_python_client.parser.properties import Schemas, build_schemas
950+
951+
components = {
952+
"Model1": oai.Schema.model_construct(
953+
type="object",
954+
properties={
955+
"prop1": oai.Schema.model_construct(type="string"),
956+
},
957+
),
958+
"Model2": oai.Schema.model_construct(
959+
allOf=[
960+
oai.Reference.model_construct(ref="#/components/schemas/Model1"),
961+
]
962+
),
963+
"Model3": oai.Schema.model_construct(
964+
allOf=[
965+
oai.Reference.model_construct(ref="#/components/schemas/Model2"),
966+
oai.Schema.model_construct(
967+
type="object",
968+
properties={
969+
"prop2": oai.Schema.model_construct(type="string"),
970+
},
971+
),
972+
],
973+
),
974+
}
975+
schemas = Schemas()
976+
977+
result = build_schemas(components=components, schemas=schemas, config=config)
978+
979+
assert result.errors == []
980+
assert result.models_to_process == []
981+
982+
# Classes should only be generated for Model1 and Model3
983+
assert result.classes_by_name.keys() == {"Model1", "Model3"}
984+
985+
# References to Model2 should be resolved to the same class as Model1
986+
assert result.classes_by_reference.keys() == {
987+
"/components/schemas/Model1",
988+
"/components/schemas/Model2",
989+
"/components/schemas/Model3",
990+
}
991+
assert (
992+
result.classes_by_reference["/components/schemas/Model2"].class_info
993+
== result.classes_by_reference["/components/schemas/Model1"].class_info
994+
)
995+
996+
# Verify that Model3 extended the properties from Model1
997+
assert [p.name for p in result.classes_by_name["Model3"].optional_properties] == ["prop1", "prop2"]
998+
975999

9761000
class TestPropogateRemoval:
9771001
def test_propogate_removal_class_name(self):

0 commit comments

Comments
 (0)
Please sign in to comment.