Skip to content

Commit db90641

Browse files
committed
allow merge of str with date/file/etc., add end-to-end tests
1 parent 2e76e02 commit db90641

File tree

8 files changed

+274
-10
lines changed

8 files changed

+274
-10
lines changed

end_to_end_tests/baseline_openapi_3.0.json

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2152,6 +2152,56 @@
21522152
"additionalProperties": {}
21532153
}
21542154
},
2155+
"ModelWithMergedProperties": {
2156+
"title": "ModelWithMergedProperties",
2157+
"allOf": [
2158+
{
2159+
"type": "object",
2160+
"properties": {
2161+
"simpleString": {
2162+
"type": "string",
2163+
"description": "base simpleString description"
2164+
},
2165+
"stringToEnum": {
2166+
"type": "string",
2167+
"default": "a"
2168+
},
2169+
"stringToDate": {
2170+
"type": "string"
2171+
},
2172+
"numberToInt": {
2173+
"type": "number"
2174+
},
2175+
"anyToString": {}
2176+
}
2177+
},
2178+
{
2179+
"type": "object",
2180+
"properties": {
2181+
"simpleString": {
2182+
"type": "string",
2183+
"description": "extended simpleString description",
2184+
"default": "new default"
2185+
},
2186+
"stringToEnum": {
2187+
"type": "string",
2188+
"enum": ["a", "b"]
2189+
},
2190+
"stringToDate": {
2191+
"type": "string",
2192+
"format": "date"
2193+
},
2194+
"numberToInt": {
2195+
"type": "integer"
2196+
},
2197+
"anyToString": {
2198+
"type": "string",
2199+
"default": "x"
2200+
}
2201+
}
2202+
}
2203+
]
2204+
},
21552205
"ModelWithPrimitiveAdditionalProperties": {
21562206
"title": "ModelWithPrimitiveAdditionalProperties",
21572207
"type": "object",

end_to_end_tests/baseline_openapi_3.1.yaml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2146,6 +2146,56 @@ info:
21462146
"additionalProperties": { }
21472147
}
21482148
},
2149+
"ModelWithMergedProperties": {
2150+
"title": "ModelWithMergedProperties",
2151+
"allOf": [
2152+
{
2153+
"type": "object",
2154+
"properties": {
2155+
"simpleString": {
2156+
"type": "string",
2157+
"description": "base simpleString description"
2158+
},
2159+
"stringToEnum": {
2160+
"type": "string",
2161+
"default": "a"
2162+
},
2163+
"stringToDate": {
2164+
"type": "string"
2165+
},
2166+
"numberToInt": {
2167+
"type": "number"
2168+
},
2169+
"anyToString": {}
2170+
}
2171+
},
2172+
{
2173+
"type": "object",
2174+
"properties": {
2175+
"simpleString": {
2176+
"type": "string",
2177+
"description": "extended simpleString description",
2178+
"default": "new default"
2179+
},
2180+
"stringToEnum": {
2181+
"type": "string",
2182+
"enum": ["a", "b"]
2183+
},
2184+
"stringToDate": {
2185+
"type": "string",
2186+
"format": "date"
2187+
},
2188+
"numberToInt": {
2189+
"type": "integer"
2190+
},
2191+
"anyToString": {
2192+
"type": "string",
2193+
"default": "x"
2194+
}
2195+
}
2196+
}
2197+
]
2198+
},
21492199
"ModelWithPrimitiveAdditionalProperties": {
21502200
"title": "ModelWithPrimitiveAdditionalProperties",
21512201
"type": "object",

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
@@ -60,6 +60,8 @@
6060
from .model_with_circular_ref_in_additional_properties_b import ModelWithCircularRefInAdditionalPropertiesB
6161
from .model_with_date_time_property import ModelWithDateTimeProperty
6262
from .model_with_discriminated_union import ModelWithDiscriminatedUnion
63+
from .model_with_merged_properties import ModelWithMergedProperties
64+
from .model_with_merged_properties_string_to_enum import ModelWithMergedPropertiesStringToEnum
6365
from .model_with_primitive_additional_properties import ModelWithPrimitiveAdditionalProperties
6466
from .model_with_primitive_additional_properties_a_date_holder import ModelWithPrimitiveAdditionalPropertiesADateHolder
6567
from .model_with_property_ref import ModelWithPropertyRef
@@ -137,6 +139,8 @@
137139
"ModelWithCircularRefInAdditionalPropertiesB",
138140
"ModelWithDateTimeProperty",
139141
"ModelWithDiscriminatedUnion",
142+
"ModelWithMergedProperties",
143+
"ModelWithMergedPropertiesStringToEnum",
140144
"ModelWithPrimitiveAdditionalProperties",
141145
"ModelWithPrimitiveAdditionalPropertiesADateHolder",
142146
"ModelWithPropertyRef",
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import datetime
2+
from typing import Any, Dict, List, Type, TypeVar, Union
3+
4+
from attrs import define as _attrs_define
5+
from attrs import field as _attrs_field
6+
from dateutil.parser import isoparse
7+
8+
from ..models.model_with_merged_properties_string_to_enum import ModelWithMergedPropertiesStringToEnum
9+
from ..types import UNSET, Unset
10+
11+
T = TypeVar("T", bound="ModelWithMergedProperties")
12+
13+
14+
@_attrs_define
15+
class ModelWithMergedProperties:
16+
"""
17+
Attributes:
18+
simple_string (Union[Unset, str]): extended simpleString description Default: 'new default'.
19+
string_to_enum (Union[Unset, ModelWithMergedPropertiesStringToEnum]):
20+
string_to_date (Union[Unset, datetime.date]):
21+
number_to_int (Union[Unset, int]):
22+
any_to_string (Union[Unset, str]): Default: 'x'.
23+
"""
24+
25+
simple_string: Union[Unset, str] = "new default"
26+
string_to_enum: Union[Unset, ModelWithMergedPropertiesStringToEnum] = UNSET
27+
string_to_date: Union[Unset, datetime.date] = UNSET
28+
number_to_int: Union[Unset, int] = UNSET
29+
any_to_string: Union[Unset, str] = "x"
30+
additional_properties: Dict[str, Any] = _attrs_field(init=False, factory=dict)
31+
32+
def to_dict(self) -> Dict[str, Any]:
33+
simple_string = self.simple_string
34+
35+
string_to_enum: Union[Unset, str] = UNSET
36+
if not isinstance(self.string_to_enum, Unset):
37+
string_to_enum = self.string_to_enum.value
38+
39+
string_to_date: Union[Unset, str] = UNSET
40+
if not isinstance(self.string_to_date, Unset):
41+
string_to_date = self.string_to_date.isoformat()
42+
43+
number_to_int = self.number_to_int
44+
45+
any_to_string = self.any_to_string
46+
47+
field_dict: Dict[str, Any] = {}
48+
field_dict.update(self.additional_properties)
49+
field_dict.update({})
50+
if simple_string is not UNSET:
51+
field_dict["simpleString"] = simple_string
52+
if string_to_enum is not UNSET:
53+
field_dict["stringToEnum"] = string_to_enum
54+
if string_to_date is not UNSET:
55+
field_dict["stringToDate"] = string_to_date
56+
if number_to_int is not UNSET:
57+
field_dict["numberToInt"] = number_to_int
58+
if any_to_string is not UNSET:
59+
field_dict["anyToString"] = any_to_string
60+
61+
return field_dict
62+
63+
@classmethod
64+
def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T:
65+
d = src_dict.copy()
66+
simple_string = d.pop("simpleString", UNSET)
67+
68+
_string_to_enum = d.pop("stringToEnum", UNSET)
69+
string_to_enum: Union[Unset, ModelWithMergedPropertiesStringToEnum]
70+
if isinstance(_string_to_enum, Unset):
71+
string_to_enum = UNSET
72+
else:
73+
string_to_enum = ModelWithMergedPropertiesStringToEnum(_string_to_enum)
74+
75+
_string_to_date = d.pop("stringToDate", UNSET)
76+
string_to_date: Union[Unset, datetime.date]
77+
if isinstance(_string_to_date, Unset):
78+
string_to_date = UNSET
79+
else:
80+
string_to_date = isoparse(_string_to_date).date()
81+
82+
number_to_int = d.pop("numberToInt", UNSET)
83+
84+
any_to_string = d.pop("anyToString", UNSET)
85+
86+
model_with_merged_properties = cls(
87+
simple_string=simple_string,
88+
string_to_enum=string_to_enum,
89+
string_to_date=string_to_date,
90+
number_to_int=number_to_int,
91+
any_to_string=any_to_string,
92+
)
93+
94+
model_with_merged_properties.additional_properties = d
95+
return model_with_merged_properties
96+
97+
@property
98+
def additional_keys(self) -> List[str]:
99+
return list(self.additional_properties.keys())
100+
101+
def __getitem__(self, key: str) -> Any:
102+
return self.additional_properties[key]
103+
104+
def __setitem__(self, key: str, value: Any) -> None:
105+
self.additional_properties[key] = value
106+
107+
def __delitem__(self, key: str) -> None:
108+
del self.additional_properties[key]
109+
110+
def __contains__(self, key: str) -> bool:
111+
return key in self.additional_properties
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from enum import Enum
2+
3+
4+
class ModelWithMergedPropertiesStringToEnum(str, Enum):
5+
A = "a"
6+
B = "b"
7+
8+
def __str__(self) -> str:
9+
return str(self.value)

openapi_python_client/parser/properties/merge_properties.py

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

3+
from openapi_python_client.parser.properties.date import DateProperty
4+
from openapi_python_client.parser.properties.datetime import DateTimeProperty
5+
from openapi_python_client.parser.properties.file import FileProperty
6+
37
__all__ = ["merge_properties"]
48

59
from typing import TypeVar, cast
@@ -19,7 +23,10 @@
1923
PropertyT = TypeVar("PropertyT", bound=PropertyProtocol)
2024

2125

22-
def merge_properties(prop1: Property, prop2: Property) -> Property | PropertyError:
26+
STRING_WITH_FORMAT_TYPES = (DateProperty, DateTimeProperty, FileProperty)
27+
28+
29+
def merge_properties(prop1: Property, prop2: Property) -> Property | PropertyError: # noqa: PLR0911
2330
"""Attempt to create a new property that incorporates the behavior of both.
2431
2532
This is used when merging schemas with allOf, when two schemas define a property with the same name.
@@ -52,6 +59,9 @@ def merge_properties(prop1: Property, prop2: Property) -> Property | PropertyErr
5259
if (merged := _merge_numeric(prop1, prop2)) is not None:
5360
return merged
5461

62+
if (merged := _merge_string_with_format(prop1, prop2)) is not None:
63+
return merged
64+
5565
return PropertyError(
5666
detail=f"{prop1.get_type_string(no_optional=True)} can't be merged with {prop2.get_type_string(no_optional=True)}"
5767
)
@@ -65,9 +75,6 @@ def _merge_same_type(prop1: Property, prop2: Property) -> Property | None | Prop
6575
# It's always OK to redefine a property with everything exactly the same
6676
return prop1
6777

68-
if isinstance(prop1, StringProperty) and isinstance(prop2, StringProperty):
69-
return _merge_string(prop1, prop2)
70-
7178
if isinstance(prop1, ListProperty) and isinstance(prop2, ListProperty):
7279
# There's no clear way to represent the intersection of two different list types. Fail in this case.
7380
inner_property = merge_properties(prop1.inner_property, prop2.inner_property) # type: ignore
@@ -80,8 +87,17 @@ def _merge_same_type(prop1: Property, prop2: Property) -> Property | None | Prop
8087
return _merge_common_attributes(prop1, prop2)
8188

8289

83-
def _merge_string(prop1: StringProperty, prop2: StringProperty) -> StringProperty | PropertyError:
84-
return _merge_common_attributes(prop1, prop2)
90+
def _merge_string_with_format(prop1: Property, prop2: Property) -> Property | None | PropertyError:
91+
"""Merge a string that has no format with a string that has a format"""
92+
# Here we need to use the DateProperty/DateTimeProperty/FileProperty as the base so that we preserve
93+
# its class, but keep the correct override order for merging the attributes.
94+
if isinstance(prop1, StringProperty) and isinstance(prop2, STRING_WITH_FORMAT_TYPES):
95+
# Use the more specific class as a base, but keep the correct override order
96+
return _merge_common_attributes(prop2, prop1, prop2)
97+
elif isinstance(prop2, StringProperty) and isinstance(prop1, STRING_WITH_FORMAT_TYPES):
98+
return _merge_common_attributes(prop1, prop2)
99+
else:
100+
return None
85101

86102

87103
def _merge_numeric(prop1: Property, prop2: Property) -> IntProperty | None | PropertyError:

tests/test_parser/test_properties/test_merge_properties.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,29 @@ def test_merge_with_incompatible_enum(
193193
if not isinstance(prop, IntProperty):
194194
assert isinstance(merge_properties(prop, int_enum_prop), PropertyError)
195195
assert isinstance(merge_properties(int_enum_prop, prop), PropertyError)
196+
197+
198+
def test_merge_string_with_formatted_string(
199+
date_property_factory,
200+
date_time_property_factory,
201+
file_property_factory,
202+
string_property_factory,
203+
):
204+
string_prop = string_property_factory(description="a plain string")
205+
string_prop_with_invalid_default = string_property_factory(default="plain string value")
206+
formatted_props = [
207+
date_property_factory(description="a date"),
208+
date_time_property_factory(description="a datetime"),
209+
file_property_factory(description="a file"),
210+
]
211+
for formatted_prop in formatted_props:
212+
merged1 = merge_properties(string_prop, formatted_prop)
213+
assert isinstance(merged1, formatted_prop.__class__)
214+
assert merged1.description == formatted_prop.description
215+
216+
merged2 = merge_properties(formatted_prop, string_prop)
217+
assert isinstance(merged2, formatted_prop.__class__)
218+
assert merged2.description == string_prop.description
219+
220+
assert isinstance(merge_properties(string_prop_with_invalid_default, formatted_prop), PropertyError)
221+
assert isinstance(merge_properties(formatted_prop, string_prop_with_invalid_default), PropertyError)

tests/test_parser/test_properties/test_model_property.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ def test_process_properties_false(self, model_property_factory, config):
325325

326326
class TestProcessProperties:
327327
def test_conflicting_properties_different_types(
328-
self, model_property_factory, string_property_factory, date_time_property_factory, config
328+
self, model_property_factory, string_property_factory, int_property_factory, config
329329
):
330330
data = oai.Schema.model_construct(
331331
allOf=[oai.Reference.model_construct(ref="#/First"), oai.Reference.model_construct(ref="#/Second")]
@@ -335,9 +335,7 @@ def test_conflicting_properties_different_types(
335335
"/First": model_property_factory(
336336
required_properties=[], optional_properties=[string_property_factory()]
337337
),
338-
"/Second": model_property_factory(
339-
required_properties=[], optional_properties=[date_time_property_factory()]
340-
),
338+
"/Second": model_property_factory(required_properties=[], optional_properties=[int_property_factory()]),
341339
}
342340
)
343341

0 commit comments

Comments
 (0)