Skip to content

Commit 5cd16ac

Browse files
committed
misc implementation changes
1 parent 850df2e commit 5cd16ac

File tree

4 files changed

+133
-67
lines changed

4 files changed

+133
-67
lines changed

.changeset/fix-model-override.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,6 @@ default: patch
44

55
# Fix overriding of object property class
66

7-
Fixed issue #1121, where redefining an object property within an `allOf` would not use the correct class name if the property's type was changed from one object type to another.
7+
Fixed issue #1123, in which a property could end up with the wrong type when combining two object schemas with `allOf`, if the type of the property was itself an object but had a different schema in each. Previously, if the property's type was A in the first schema and B in the second, the resulting schema would use type A for the property.
8+
9+
The new behavior is, that the generator will test whether one of the types A/B is derived from the other. "Derived" here means that the result of `allOf[A, B]` would be exactly identical to B. If so, it will use the class name of B. If not, it will attempt to merge A and B with the usual `allOf` logic to create a new inline schema.

openapi_python_client/parser/properties/merge_properties.py

Lines changed: 77 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
from __future__ import annotations
2+
from itertools import chain
23

4+
from openapi_python_client import utils
5+
from openapi_python_client.config import Config
36
from openapi_python_client.parser.properties.date import DateProperty
47
from openapi_python_client.parser.properties.datetime import DateTimeProperty
58
from openapi_python_client.parser.properties.file import FileProperty
6-
from openapi_python_client.parser.properties.model_property import ModelProperty
9+
from openapi_python_client.parser.properties.model_property import ModelDetails, ModelProperty, _gather_property_data
10+
from openapi_python_client.parser.properties.schemas import Class, Schemas
711

812
__all__ = ["merge_properties"]
913

@@ -27,7 +31,12 @@
2731
STRING_WITH_FORMAT_TYPES = (DateProperty, DateTimeProperty, FileProperty)
2832

2933

30-
def merge_properties(prop1: Property, prop2: Property) -> Property | PropertyError: # noqa: PLR0911
34+
def merge_properties(
35+
prop1: Property,
36+
prop2: Property,
37+
parent_name: str,
38+
config: Config,
39+
) -> Property | PropertyError: # noqa: PLR0911
3140
"""Attempt to create a new property that incorporates the behavior of both.
3241
3342
This is used when merging schemas with allOf, when two schemas define a property with the same name.
@@ -54,7 +63,7 @@ def merge_properties(prop1: Property, prop2: Property) -> Property | PropertyErr
5463
if isinstance(prop1, EnumProperty) or isinstance(prop2, EnumProperty):
5564
return _merge_with_enum(prop1, prop2)
5665

57-
if (merged := _merge_same_type(prop1, prop2)) is not None:
66+
if (merged := _merge_same_type(prop1, prop2, parent_name, config)) is not None:
5867
return merged
5968

6069
if (merged := _merge_numeric(prop1, prop2)) is not None:
@@ -68,7 +77,7 @@ def merge_properties(prop1: Property, prop2: Property) -> Property | PropertyErr
6877
)
6978

7079

71-
def _merge_same_type(prop1: Property, prop2: Property) -> Property | None | PropertyError:
80+
def _merge_same_type(prop1: Property, prop2: Property, parent_name: str, config: Config) -> Property | None | PropertyError:
7281
if type(prop1) is not type(prop2):
7382
return None
7483

@@ -77,10 +86,10 @@ def _merge_same_type(prop1: Property, prop2: Property) -> Property | None | Prop
7786
return prop1
7887

7988
if isinstance(prop1, ModelProperty) and isinstance(prop2, ModelProperty):
80-
return _merge_models(prop1, prop2)
89+
return _merge_models(prop1, prop2, parent_name, config)
8190

8291
if isinstance(prop1, ListProperty) and isinstance(prop2, ListProperty):
83-
inner_property = merge_properties(prop1.inner_property, prop2.inner_property) # type: ignore
92+
inner_property = merge_properties(prop1.inner_property, prop2.inner_property, "", config) # type: ignore
8493
if isinstance(inner_property, PropertyError):
8594
return PropertyError(detail=f"can't merge list properties: {inner_property.detail}")
8695
prop1.inner_property = inner_property
@@ -90,22 +99,67 @@ def _merge_same_type(prop1: Property, prop2: Property) -> Property | None | Prop
9099
return _merge_common_attributes(prop1, prop2)
91100

92101

93-
def _merge_models(prop1: ModelProperty, prop2: ModelProperty) -> Property | PropertyError:
102+
def _merge_models(prop1: ModelProperty, prop2: ModelProperty, parent_name: str, config: Config) -> Property | PropertyError:
94103
# Ideally, we would treat this case the same as a schema that consisted of "allOf: [prop1, prop2]",
95104
# applying the property merge logic recursively and creating a new third schema if the result could
96105
# not be fully described by one or the other. But for now we will just handle the common case where
97106
# B is an object type that extends A and fully includes it, with no changes to any of A's properties;
98107
# in that case, it is valid to just reuse the model class for B.
99108
for prop in [prop1, prop2]:
100109
if prop.needs_processing():
110+
# This means not all of the details of the schema have been filled in, possibly due to a
111+
# forward reference. That may be resolved in a later pass, but for now we can't proceed.
101112
return PropertyError(f"Schema for {prop} in allOf was not processed", data=prop)
102-
if _model_is_extension_of(prop1, prop2):
103-
extended_model = prop1
104-
elif _model_is_extension_of(prop2, prop1):
105-
extended_model = prop2
106-
else:
107-
return PropertyError(detail="unable to merge two unrelated object types for this property")
108-
return _merge_common_attributes(extended_model, prop1, prop2)
113+
114+
# Detect whether one of the schemas is derived from the other-- that is, if it is (or is equivalent
115+
# to) the result of taking the other type and adding/modifying properties with allOf. If so, then
116+
# we can simply use the class of the derived type. We will still call _merge_common_attributes in
117+
# case any metadata like "description" has been modified.
118+
if _model_is_extension_of(prop1, prop2, parent_name, config):
119+
return _merge_common_attributes(prop1, prop2)
120+
elif _model_is_extension_of(prop2, prop1, parent_name, config):
121+
return _merge_common_attributes(prop2, prop1, prop2)
122+
123+
# Neither of the schemas is a superset of the other, so merging them will result in a new type.
124+
merged_props: dict[str, Property] = {p.name: p for p in chain(prop1.required_properties, prop1.optional_properties)}
125+
for model in [prop1, prop2]:
126+
for sub_prop in chain(model.required_properties, model.optional_properties):
127+
if sub_prop.name in merged_props:
128+
merged_prop = merge_properties(merged_props[sub_prop.name], sub_prop, parent_name, config)
129+
if isinstance(merged_prop, PropertyError):
130+
return merged_prop
131+
merged_props[sub_prop.name] = merged_prop
132+
else:
133+
merged_props[sub_prop.name] = sub_prop
134+
135+
prop_data = _gather_property_data(merged_props.values(), Schemas())
136+
137+
name = prop2.name
138+
class_string = f"{utils.pascal_case(parent_name)}{utils.pascal_case(name)}"
139+
class_info = Class.from_string(string=class_string, config=config)
140+
roots = prop1.roots.union(prop2.roots).difference({prop1.class_info.name, prop2.class_info.name})
141+
roots.add(class_info.name)
142+
prop_details = ModelDetails(
143+
required_properties=prop_data.required_props,
144+
optional_properties=prop_data.optional_props,
145+
additional_properties=None,
146+
relative_imports=prop_data.relative_imports,
147+
lazy_imports=prop_data.lazy_imports,
148+
)
149+
prop = ModelProperty(
150+
class_info=class_info,
151+
data=prop2.data, # TODO: not sure what this should be
152+
roots=roots,
153+
details=prop_details,
154+
description=prop2.description or prop1.description,
155+
default=None,
156+
required=prop2.required or prop1.required,
157+
name=name,
158+
python_name=utils.PythonIdentifier(value=name, prefix=config.field_prefix),
159+
example=prop2.example or prop1.example,
160+
)
161+
162+
return prop
109163

110164

111165
def _merge_string_with_format(prop1: Property, prop2: Property) -> Property | None | PropertyError:
@@ -190,17 +244,19 @@ def _values_are_subset(prop1: EnumProperty, prop2: EnumProperty) -> bool:
190244
return set(prop1.values.items()) <= set(prop2.values.items())
191245

192246

193-
def _model_is_extension_of(extended_model: ModelProperty, base_model: ModelProperty) -> bool:
194-
def _list_is_extension_of(extended_list: list[Property], base_list: list[Property]) -> bool:
247+
def _model_is_extension_of(extended_model: ModelProperty, base_model: ModelProperty, parent_name: str, config: Config) -> bool:
248+
def _properties_are_extension_of(extended_list: list[Property], base_list: list[Property]) -> bool:
195249
for p2 in base_list:
196-
if not [p1 for p1 in extended_list if _property_is_extension_of(p2, p1)]:
250+
if not [p1 for p1 in extended_list if _property_is_extension_of(p2, p1, parent_name, config)]:
197251
return False
198252
return True
199253

200-
return _list_is_extension_of(
254+
return _properties_are_extension_of(
201255
extended_model.required_properties, base_model.required_properties
202-
) and _list_is_extension_of(extended_model.optional_properties, base_model.optional_properties)
256+
) and _properties_are_extension_of(extended_model.optional_properties, base_model.optional_properties)
203257

204258

205-
def _property_is_extension_of(extended_prop: PropertyProtocol, base_prop: PropertyProtocol) -> bool:
206-
return base_prop.name == extended_prop.name and merge_properties(base_prop, extended_prop) == extended_prop
259+
def _property_is_extension_of(extended_prop: PropertyProtocol, base_prop: PropertyProtocol, parent_name: str, config: Config) -> bool:
260+
return base_prop.name == extended_prop.name and (
261+
base_prop == extended_prop or merge_properties(base_prop, extended_prop, parent_name, config) == extended_prop
262+
)

openapi_python_client/parser/properties/model_property.py

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

33
from dataclasses import dataclass
44
from itertools import chain
5-
from typing import Any, ClassVar, NamedTuple
5+
from typing import Any, ClassVar, Iterable, NamedTuple
66

77
from attrs import define, evolve
88

@@ -261,7 +261,7 @@ class _PropertyData(NamedTuple):
261261
schemas: Schemas
262262

263263

264-
def _process_properties( # noqa: PLR0912, PLR0911
264+
def _process_properties( # noqa: PLR0911
265265
*,
266266
data: oai.Schema,
267267
schemas: Schemas,
@@ -273,15 +273,13 @@ def _process_properties( # noqa: PLR0912, PLR0911
273273
from .merge_properties import merge_properties
274274

275275
properties: dict[str, Property] = {}
276-
relative_imports: set[str] = set()
277-
lazy_imports: set[str] = set()
278276
required_set = set(data.required or [])
279277

280278
def _add_if_no_conflict(new_prop: Property) -> PropertyError | None:
281279
nonlocal properties
282280

283281
name_conflict = properties.get(new_prop.name)
284-
merged_prop = merge_properties(name_conflict, new_prop) if name_conflict else new_prop
282+
merged_prop = merge_properties(name_conflict, new_prop, class_name, config) if name_conflict else new_prop
285283
if isinstance(merged_prop, PropertyError):
286284
merged_prop.header = f"Found conflicting properties named {new_prop.name} when creating {class_name}"
287285
return merged_prop
@@ -340,17 +338,22 @@ def _add_if_no_conflict(new_prop: Property) -> PropertyError | None:
340338
if isinstance(prop_or_error, PropertyError):
341339
return prop_or_error
342340

341+
return _gather_property_data(properties.values(), schemas)
342+
343+
344+
def _gather_property_data(properties: Iterable[Property], schemas: Schemas) -> _PropertyData:
343345
required_properties = []
344346
optional_properties = []
345-
for prop in properties.values():
347+
relative_imports: set[str] = set()
348+
lazy_imports: set[str] = set()
349+
for prop in properties:
346350
if prop.required:
347351
required_properties.append(prop)
348352
else:
349353
optional_properties.append(prop)
350354

351355
lazy_imports.update(prop.get_lazy_imports(prefix=".."))
352356
relative_imports.update(prop.get_imports(prefix=".."))
353-
354357
return _PropertyData(
355358
optional_props=optional_properties,
356359
required_props=required_properties,

0 commit comments

Comments
 (0)