Skip to content

Commit 850df2e

Browse files
committed
fix: use correct class name when overriding model property
1 parent a61a741 commit 850df2e

File tree

8 files changed

+241
-42
lines changed

8 files changed

+241
-42
lines changed

.changeset/fix-model-override.md

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+
# Fix overriding of object property class
6+
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.

openapi_python_client/parser/properties/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ def _property_from_ref(
125125
return prop, schemas
126126

127127

128-
def property_from_data( # noqa: PLR0911, PLR0912
128+
def property_from_data( # noqa: PLR0911
129129
name: str,
130130
required: bool,
131131
data: oai.Reference | oai.Schema,

openapi_python_client/parser/properties/merge_properties.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from openapi_python_client.parser.properties.date import DateProperty
44
from openapi_python_client.parser.properties.datetime import DateTimeProperty
55
from openapi_python_client.parser.properties.file import FileProperty
6+
from openapi_python_client.parser.properties.model_property import ModelProperty
67

78
__all__ = ["merge_properties"]
89

@@ -75,6 +76,9 @@ def _merge_same_type(prop1: Property, prop2: Property) -> Property | None | Prop
7576
# It's always OK to redefine a property with everything exactly the same
7677
return prop1
7778

79+
if isinstance(prop1, ModelProperty) and isinstance(prop2, ModelProperty):
80+
return _merge_models(prop1, prop2)
81+
7882
if isinstance(prop1, ListProperty) and isinstance(prop2, ListProperty):
7983
inner_property = merge_properties(prop1.inner_property, prop2.inner_property) # type: ignore
8084
if isinstance(inner_property, PropertyError):
@@ -86,6 +90,24 @@ def _merge_same_type(prop1: Property, prop2: Property) -> Property | None | Prop
8690
return _merge_common_attributes(prop1, prop2)
8791

8892

93+
def _merge_models(prop1: ModelProperty, prop2: ModelProperty) -> Property | PropertyError:
94+
# Ideally, we would treat this case the same as a schema that consisted of "allOf: [prop1, prop2]",
95+
# applying the property merge logic recursively and creating a new third schema if the result could
96+
# not be fully described by one or the other. But for now we will just handle the common case where
97+
# B is an object type that extends A and fully includes it, with no changes to any of A's properties;
98+
# in that case, it is valid to just reuse the model class for B.
99+
for prop in [prop1, prop2]:
100+
if prop.needs_processing():
101+
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)
109+
110+
89111
def _merge_string_with_format(prop1: Property, prop2: Property) -> Property | None | PropertyError:
90112
"""Merge a string that has no format with a string that has a format"""
91113
# Here we need to use the DateProperty/DateTimeProperty/FileProperty as the base so that we preserve
@@ -166,3 +188,19 @@ def _merge_common_attributes(base: PropertyT, *extend_with: PropertyProtocol) ->
166188

167189
def _values_are_subset(prop1: EnumProperty, prop2: EnumProperty) -> bool:
168190
return set(prop1.values.items()) <= set(prop2.values.items())
191+
192+
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:
195+
for p2 in base_list:
196+
if not [p1 for p1 in extended_list if _property_is_extension_of(p2, p1)]:
197+
return False
198+
return True
199+
200+
return _list_is_extension_of(
201+
extended_model.required_properties, base_model.required_properties
202+
) and _list_is_extension_of(extended_model.optional_properties, base_model.optional_properties)
203+
204+
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

openapi_python_client/parser/properties/model_property.py

Lines changed: 51 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from dataclasses import dataclass
34
from itertools import chain
45
from typing import Any, ClassVar, NamedTuple
56

@@ -14,6 +15,15 @@
1415
from .schemas import Class, ReferencePath, Schemas, parse_reference_path
1516

1617

18+
@dataclass
19+
class ModelDetails:
20+
required_properties: list[Property] | None = None
21+
optional_properties: list[Property] | None = None
22+
additional_properties: Property | None = None
23+
relative_imports: set[str] | None = None
24+
lazy_imports: set[str] | None = None
25+
26+
1727
@define
1828
class ModelProperty(PropertyProtocol):
1929
"""A property which refers to another Schema"""
@@ -27,11 +37,7 @@ class ModelProperty(PropertyProtocol):
2737
data: oai.Schema
2838
description: str
2939
roots: set[ReferencePath | utils.ClassName]
30-
required_properties: list[Property] | None
31-
optional_properties: list[Property] | None
32-
relative_imports: set[str] | None
33-
lazy_imports: set[str] | None
34-
additional_properties: Property | None
40+
details: ModelDetails
3541
_json_type_string: ClassVar[str] = "Dict[str, Any]"
3642

3743
template: ClassVar[str] = "model_property.py.jinja"
@@ -75,22 +81,18 @@ def build(
7581
class_string = title
7682
class_info = Class.from_string(string=class_string, config=config)
7783
model_roots = {*roots, class_info.name}
78-
required_properties: list[Property] | None = None
79-
optional_properties: list[Property] | None = None
80-
relative_imports: set[str] | None = None
81-
lazy_imports: set[str] | None = None
82-
additional_properties: Property | None = None
84+
details = ModelDetails()
8385
if process_properties:
8486
data_or_err, schemas = _process_property_data(
8587
data=data, schemas=schemas, class_info=class_info, config=config, roots=model_roots
8688
)
8789
if isinstance(data_or_err, PropertyError):
8890
return data_or_err, schemas
89-
property_data, additional_properties = data_or_err
90-
required_properties = property_data.required_props
91-
optional_properties = property_data.optional_props
92-
relative_imports = property_data.relative_imports
93-
lazy_imports = property_data.lazy_imports
91+
property_data, details.additional_properties = data_or_err
92+
details.required_properties = property_data.required_props
93+
details.optional_properties = property_data.optional_props
94+
details.relative_imports = property_data.relative_imports
95+
details.lazy_imports = property_data.lazy_imports
9496
for root in roots:
9597
if isinstance(root, utils.ClassName):
9698
continue
@@ -100,11 +102,7 @@ def build(
100102
class_info=class_info,
101103
data=data,
102104
roots=model_roots,
103-
required_properties=required_properties,
104-
optional_properties=optional_properties,
105-
relative_imports=relative_imports,
106-
lazy_imports=lazy_imports,
107-
additional_properties=additional_properties,
105+
details=details,
108106
description=data.description or "",
109107
default=None,
110108
required=required,
@@ -125,14 +123,39 @@ def build(
125123
)
126124
return prop, schemas
127125

126+
def needs_processing(self) -> bool:
127+
return not (
128+
isinstance(self.details.required_properties, list) and isinstance(self.details.optional_properties, list)
129+
)
130+
131+
@property
132+
def required_properties(self) -> list[Property]:
133+
return self.details.required_properties or []
134+
135+
@property
136+
def optional_properties(self) -> list[Property]:
137+
return self.details.optional_properties or []
138+
139+
@property
140+
def additional_properties(self) -> Property | None:
141+
return self.details.additional_properties
142+
143+
@property
144+
def relative_imports(self) -> set[str]:
145+
return self.details.relative_imports or set()
146+
147+
@property
148+
def lazy_imports(self) -> set[str] | None:
149+
return self.details.lazy_imports or set()
150+
128151
@classmethod
129152
def convert_value(cls, value: Any) -> Value | None | PropertyError:
130153
if value is not None:
131154
return PropertyError(detail="ModelProperty cannot have a default value") # pragma: no cover
132155
return None
133156

134157
def __attrs_post_init__(self) -> None:
135-
if self.relative_imports:
158+
if self.details.relative_imports:
136159
self.set_relative_imports(self.relative_imports)
137160

138161
@property
@@ -175,15 +198,15 @@ def set_relative_imports(self, relative_imports: set[str]) -> None:
175198
Args:
176199
relative_imports: The set of relative import strings
177200
"""
178-
object.__setattr__(self, "relative_imports", {ri for ri in relative_imports if self.self_import not in ri})
201+
self.details.relative_imports = {ri for ri in relative_imports if self.self_import not in ri}
179202

180203
def set_lazy_imports(self, lazy_imports: set[str]) -> None:
181204
"""Set the lazy imports set for this ModelProperty, filtering out self imports
182205
183206
Args:
184207
lazy_imports: The set of lazy import strings
185208
"""
186-
object.__setattr__(self, "lazy_imports", {li for li in lazy_imports if self.self_import not in li})
209+
self.details.lazy_imports = {li for li in lazy_imports if self.self_import not in li}
187210

188211
def get_type_string(
189212
self,
@@ -289,9 +312,7 @@ def _add_if_no_conflict(new_prop: Property) -> PropertyError | None:
289312
if not isinstance(sub_model, ModelProperty):
290313
return PropertyError("Cannot take allOf a non-object")
291314
# Properties of allOf references first should be processed first
292-
if not (
293-
isinstance(sub_model.required_properties, list) and isinstance(sub_model.optional_properties, list)
294-
):
315+
if sub_model.needs_processing():
295316
return PropertyError(f"Reference {sub_model.name} in allOf was not processed", data=sub_prop)
296317
for prop in chain(sub_model.required_properties, sub_model.optional_properties):
297318
err = _add_if_no_conflict(prop)
@@ -437,9 +458,10 @@ def process_model(model_prop: ModelProperty, *, schemas: Schemas, config: Config
437458

438459
property_data, additional_properties = data_or_err
439460

440-
object.__setattr__(model_prop, "required_properties", property_data.required_props)
441-
object.__setattr__(model_prop, "optional_properties", property_data.optional_props)
461+
model_prop.details.required_properties = property_data.required_props
462+
model_prop.details.optional_properties = property_data.optional_props
463+
model_prop.details.additional_properties = additional_properties
442464
model_prop.set_relative_imports(property_data.relative_imports)
443465
model_prop.set_lazy_imports(property_data.lazy_imports)
444-
object.__setattr__(model_prop, "additional_properties", additional_properties)
466+
445467
return schemas

openapi_python_client/parser/properties/protocol.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,7 @@ def is_base_type(self) -> bool:
185185
ListProperty.__name__,
186186
UnionProperty.__name__,
187187
}
188+
189+
def needs_processing(self) -> bool:
190+
"""Returns true if the parser should call process_model() on this property in a second pass."""
191+
return False

tests/conftest.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
UnionProperty,
2626
)
2727
from openapi_python_client.parser.properties.float import FloatProperty
28+
from openapi_python_client.parser.properties.model_property import ModelDetails
2829
from openapi_python_client.parser.properties.protocol import PropertyType, Value
2930
from openapi_python_client.schema.openapi_schema_pydantic import Parameter
3031
from openapi_python_client.schema.parameter_location import ParameterLocation
@@ -64,15 +65,25 @@ def _factory(**kwargs):
6465
"class_info": Class(name=ClassName("MyClass", ""), module_name=PythonIdentifier("my_module", "")),
6566
"data": oai.Schema.model_construct(),
6667
"roots": set(),
67-
"required_properties": None,
68-
"optional_properties": None,
69-
"relative_imports": None,
70-
"lazy_imports": None,
71-
"additional_properties": None,
7268
"python_name": "",
7369
"example": "",
7470
**kwargs,
7571
}
72+
# shortcuts for setting attributes within ModelDetails
73+
if "details" not in kwargs:
74+
detail_args = {}
75+
for arg_name in [
76+
"required_properties",
77+
"optional_properties",
78+
"additional_properties",
79+
"relative_imports",
80+
"lazy_imports",
81+
]:
82+
if arg_name in kwargs:
83+
detail_args[arg_name] = kwargs[arg_name]
84+
kwargs.pop(arg_name)
85+
kwargs["details"] = ModelDetails(**detail_args)
86+
7687
return ModelProperty(**kwargs)
7788

7889
return _factory

0 commit comments

Comments
 (0)