Skip to content

Commit 70060bf

Browse files
committed
fix: Handle duplicate and conflicting properties in allOf
1 parent a3f8fa8 commit 70060bf

File tree

2 files changed

+149
-10
lines changed

2 files changed

+149
-10
lines changed

openapi_python_client/parser/properties/model_property.py

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from typing import ClassVar, List, NamedTuple, Optional, Set, Tuple, Union
1+
from itertools import chain
2+
from typing import ClassVar, Dict, List, NamedTuple, Optional, Set, Tuple, Union
23

34
import attr
45

@@ -54,6 +55,18 @@ def get_imports(self, *, prefix: str) -> Set[str]:
5455
return imports
5556

5657

58+
def _merge_properties(first: Property, second: Property) -> Union[Property, PropertyError]:
59+
if first.__class__ != second.__class__:
60+
return PropertyError(header="Cannot merge properties", detail="Properties are two different types")
61+
nullable = first.nullable and second.nullable
62+
required = first.required or second.required
63+
first = attr.evolve(first, nullable=nullable, required=required)
64+
second = attr.evolve(second, nullable=nullable, required=required)
65+
if first != second:
66+
return PropertyError(header="Cannot merge properties", detail="Properties has conflicting values")
67+
return first
68+
69+
5770
class _PropertyData(NamedTuple):
5871
optional_props: List[Property]
5972
required_props: List[Property]
@@ -64,33 +77,50 @@ class _PropertyData(NamedTuple):
6477
def _process_properties(*, data: oai.Schema, schemas: Schemas, class_name: str) -> Union[_PropertyData, PropertyError]:
6578
from . import property_from_data
6679

67-
required_properties: List[Property] = []
68-
optional_properties: List[Property] = []
80+
properties: Dict[str, Property] = {}
6981
relative_imports: Set[str] = set()
7082
required_set = set(data.required or [])
7183

84+
def _check_existing(prop: Property) -> Union[Property, PropertyError]:
85+
existing = properties.get(prop.name)
86+
prop_or_error = (existing and _merge_properties(existing, prop)) or prop
87+
if isinstance(prop_or_error, PropertyError):
88+
prop_or_error.header = f"Found conflicting properties named {prop.name} when creating {class_name}"
89+
return prop_or_error
90+
properties[prop_or_error.name] = prop_or_error
91+
return prop_or_error
92+
7293
all_props = data.properties or {}
7394
for sub_prop in data.allOf or []:
7495
if isinstance(sub_prop, oai.Reference):
7596
source_name = Reference.from_ref(sub_prop.ref).class_name
7697
sub_model = schemas.models.get(source_name)
7798
if sub_model is None:
7899
return PropertyError(f"Reference {sub_prop.ref} not found")
79-
required_properties.extend(sub_model.required_properties)
80-
optional_properties.extend(sub_model.optional_properties)
81-
relative_imports.update(sub_model.relative_imports)
100+
for prop in chain(sub_model.required_properties, sub_model.optional_properties):
101+
prop_or_error = _check_existing(prop)
102+
if isinstance(prop_or_error, PropertyError):
103+
return prop_or_error
82104
else:
83105
all_props.update(sub_prop.properties or {})
84106
required_set.update(sub_prop.required or [])
85107

86108
for key, value in all_props.items():
87109
prop_required = key in required_set
88-
prop, schemas = property_from_data(
110+
prop_or_error, schemas = property_from_data(
89111
name=key, required=prop_required, data=value, schemas=schemas, parent_name=class_name
90112
)
91-
if isinstance(prop, PropertyError):
92-
return prop
93-
if prop_required and not prop.nullable:
113+
if isinstance(prop_or_error, Property):
114+
prop_or_error = _check_existing(prop_or_error)
115+
if isinstance(prop_or_error, PropertyError):
116+
return prop_or_error
117+
118+
properties[prop_or_error.name] = prop_or_error
119+
120+
required_properties = []
121+
optional_properties = []
122+
for prop in properties.values():
123+
if prop.required and not prop.nullable:
94124
required_properties.append(prop)
95125
else:
96126
optional_properties.append(prop)

tests/test_parser/test_properties/test_model_property.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Callable
2+
13
import pytest
24

35
import openapi_python_client.schema as oai
@@ -202,3 +204,110 @@ def test_bad_additional_props_return_error(self):
202204

203205
assert new_schemas == schemas
204206
assert err == PropertyError(detail="unknown type not_real", data=oai.Schema(type="not_real"))
207+
208+
209+
@pytest.fixture
210+
def model_property() -> Callable[..., ModelProperty]:
211+
from openapi_python_client.parser.reference import Reference
212+
213+
def _factory(**kwargs):
214+
kwargs = {
215+
"name": "",
216+
"description": "",
217+
"required": True,
218+
"nullable": True,
219+
"default": None,
220+
"reference": Reference(class_name="", module_name=""),
221+
"required_properties": [],
222+
"optional_properties": [],
223+
"relative_imports": set(),
224+
"additional_properties": False,
225+
**kwargs,
226+
}
227+
return ModelProperty(**kwargs)
228+
229+
return _factory
230+
231+
232+
def string_property(**kwargs) -> StringProperty:
233+
kwargs = {
234+
"name": "",
235+
"required": True,
236+
"nullable": True,
237+
"default": None,
238+
**kwargs,
239+
}
240+
return StringProperty(**kwargs)
241+
242+
243+
class TestProcessProperties:
244+
def test_conflicting_properties(self, model_property):
245+
from openapi_python_client.parser.properties import Schemas
246+
from openapi_python_client.parser.properties.model_property import _process_properties
247+
248+
data = oai.Schema.construct(allOf=[oai.Reference.construct(ref="First"), oai.Reference.construct(ref="Second")])
249+
schemas = Schemas(
250+
models={
251+
"First": model_property(
252+
optional_properties=[StringProperty(name="prop", required=True, nullable=True, default=None)]
253+
),
254+
"Second": model_property(
255+
optional_properties=[DateTimeProperty(name="prop", required=True, nullable=True, default=None)]
256+
),
257+
}
258+
)
259+
260+
result = _process_properties(data=data, schemas=schemas, class_name="")
261+
262+
assert isinstance(result, PropertyError)
263+
264+
def test_duplicate_properties(self, model_property):
265+
from openapi_python_client.parser.properties import Schemas
266+
from openapi_python_client.parser.properties.model_property import _process_properties
267+
268+
data = oai.Schema.construct(allOf=[oai.Reference.construct(ref="First"), oai.Reference.construct(ref="Second")])
269+
prop = string_property()
270+
schemas = Schemas(
271+
models={
272+
"First": model_property(optional_properties=[prop]),
273+
"Second": model_property(optional_properties=[prop]),
274+
}
275+
)
276+
277+
result = _process_properties(data=data, schemas=schemas, class_name="")
278+
279+
assert result.optional_props == [prop], "There should only be one copy of duplicate properties"
280+
281+
@pytest.mark.parametrize("first_nullable", [True, False])
282+
@pytest.mark.parametrize("second_nullable", [True, False])
283+
@pytest.mark.parametrize("first_required", [True, False])
284+
@pytest.mark.parametrize("second_required", [True, False])
285+
def test_mixed_requirements(self, model_property, first_nullable, second_nullable, first_required, second_required):
286+
from openapi_python_client.parser.properties import Schemas
287+
from openapi_python_client.parser.properties.model_property import _process_properties
288+
289+
data = oai.Schema.construct(allOf=[oai.Reference.construct(ref="First"), oai.Reference.construct(ref="Second")])
290+
schemas = Schemas(
291+
models={
292+
"First": model_property(
293+
optional_properties=[string_property(required=first_required, nullable=first_nullable)]
294+
),
295+
"Second": model_property(
296+
optional_properties=[string_property(required=second_required, nullable=second_nullable)]
297+
),
298+
}
299+
)
300+
301+
result = _process_properties(data=data, schemas=schemas, class_name="")
302+
303+
nullable = first_nullable and second_nullable
304+
required = first_required or second_required
305+
expected_prop = string_property(
306+
nullable=nullable,
307+
required=required,
308+
)
309+
310+
if nullable or not required:
311+
assert result.optional_props == [expected_prop]
312+
else:
313+
assert result.required_props == [expected_prop]

0 commit comments

Comments
 (0)