-
-
Notifications
You must be signed in to change notification settings - Fork 227
/
Copy pathmerge_properties.py
302 lines (247 loc) · 14.4 KB
/
merge_properties.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
from __future__ import annotations
from itertools import chain
from openapi_python_client import schema as oai
from openapi_python_client import utils
from openapi_python_client.config import Config
from openapi_python_client.parser.properties.date import DateProperty
from openapi_python_client.parser.properties.datetime import DateTimeProperty
from openapi_python_client.parser.properties.file import FileProperty
from openapi_python_client.parser.properties.literal_enum_property import LiteralEnumProperty
from openapi_python_client.parser.properties.model_property import ModelDetails, ModelProperty, _gather_property_data
from openapi_python_client.parser.properties.schemas import Class, Schemas
__all__ = ["merge_properties"]
from typing import TypeVar, cast
from attr import evolve
from ..errors import PropertyError
from . import FloatProperty
from .any import AnyProperty
from .enum_property import EnumProperty
from .int import IntProperty
from .list_property import ListProperty
from .property import Property
from .protocol import PropertyProtocol
from .string import StringProperty
PropertyT = TypeVar("PropertyT", bound=PropertyProtocol)
STRING_WITH_FORMAT_TYPES = (DateProperty, DateTimeProperty, FileProperty)
def merge_properties( # noqa:PLR0911
prop1: Property,
prop2: Property,
parent_name: str,
config: Config,
) -> Property | PropertyError:
"""Attempt to create a new property that incorporates the behavior of both.
This is used when merging schemas with allOf, when two schemas define a property with the same name.
OpenAPI defines allOf in terms of validation behavior: the input must pass the validation rules
defined in all the listed schemas. Our task here is slightly more difficult, since we must end
up with a single Property object that will be used to generate a single class property in the
generated code. Due to limitations of our internal model, this may not be possible for some
combinations of property attributes that OpenAPI supports (for instance, we have no way to represent
a string property that must match two different regexes).
Properties can also have attributes that do not represent validation rules, such as "description"
and "example". OpenAPI does not define any overriding/aggregation rules for these in allOf. The
implementation here is, assuming prop1 and prop2 are in the same order that the schemas were in the
allOf, any such attributes that prop2 specifies will override the ones from prop1.
"""
if isinstance(prop2, AnyProperty):
return _merge_common_attributes(prop1, prop2)
if isinstance(prop1, AnyProperty):
# Use the base type of `prop2`, but keep the override order
return _merge_common_attributes(prop2, prop1, prop2)
if isinstance(prop1, EnumProperty) or isinstance(prop2, EnumProperty):
return _merge_with_enum(prop1, prop2)
if isinstance(prop1, LiteralEnumProperty) or isinstance(prop2, LiteralEnumProperty):
return _merge_with_literal_enum(prop1, prop2)
if (merged := _merge_same_type(prop1, prop2, parent_name, config)) is not None:
return merged
if (merged := _merge_numeric(prop1, prop2)) is not None:
return merged
if (merged := _merge_string_with_format(prop1, prop2)) is not None:
return merged
return PropertyError(
detail=f"{prop1.get_type_string(no_optional=True)} can't be merged with {prop2.get_type_string(no_optional=True)}"
)
def _merge_same_type(
prop1: Property, prop2: Property, parent_name: str, config: Config
) -> Property | None | PropertyError:
if type(prop1) is not type(prop2):
return None
if prop1 == prop2:
# It's always OK to redefine a property with everything exactly the same
return prop1
if isinstance(prop1, ModelProperty) and isinstance(prop2, ModelProperty):
return _merge_models(prop1, prop2, parent_name, config)
if isinstance(prop1, ListProperty) and isinstance(prop2, ListProperty):
inner_property = merge_properties(prop1.inner_property, prop2.inner_property, "", config) # type: ignore
if isinstance(inner_property, PropertyError):
return PropertyError(detail=f"can't merge list properties: {inner_property.detail}")
prop1.inner_property = inner_property
# For all other property types, there aren't any special attributes that affect validation, so just
# apply the rules for common attributes like "description".
return _merge_common_attributes(prop1, prop2)
def _merge_models(
prop1: ModelProperty, prop2: ModelProperty, parent_name: str, config: Config
) -> Property | PropertyError:
# Ideally, we would treat this case the same as a schema that consisted of "allOf: [prop1, prop2]",
# applying the property merge logic recursively and creating a new third schema if the result could
# not be fully described by one or the other. But for now we will just handle the common case where
# B is an object type that extends A and fully includes it, with no changes to any of A's properties;
# in that case, it is valid to just reuse the model class for B.
for prop in [prop1, prop2]:
if prop.needs_post_processing():
# This means not all of the details of the schema have been filled in, possibly due to a
# forward reference. That may be resolved in a later pass, but for now we can't proceed.
return PropertyError(f"Schema for {prop} in allOf was not processed", data=prop.data)
# Detect whether one of the schemas is derived from the other-- that is, if it is (or is equivalent
# to) the result of taking the other type and adding/modifying properties with allOf. If so, then
# we can simply use the class of the derived type. We will still call _merge_common_attributes in
# case any metadata like "description" has been modified.
if _model_is_extension_of(prop1, prop2, parent_name, config):
return _merge_common_attributes(prop1, prop2)
elif _model_is_extension_of(prop2, prop1, parent_name, config):
return _merge_common_attributes(prop2, prop1, prop2)
# Neither of the schemas is a superset of the other, so merging them will result in a new type.
merged_props: dict[str, Property] = {p.name: p for p in chain(prop1.required_properties, prop1.optional_properties)}
for model in [prop1, prop2]:
for sub_prop in chain(model.required_properties, model.optional_properties):
if sub_prop.name in merged_props:
merged_prop = merge_properties(merged_props[sub_prop.name], sub_prop, parent_name, config)
if isinstance(merged_prop, PropertyError):
return merged_prop
merged_props[sub_prop.name] = merged_prop
else:
merged_props[sub_prop.name] = sub_prop
prop_data = _gather_property_data(merged_props.values(), Schemas())
name = prop2.name
class_string = f"{utils.pascal_case(parent_name)}{utils.pascal_case(name)}"
class_info = Class.from_string(string=class_string, config=config)
roots = prop1.roots.union(prop2.roots).difference({prop1.class_info.name, prop2.class_info.name})
roots.add(class_info.name)
prop_details = ModelDetails(
required_properties=prop_data.required_props,
optional_properties=prop_data.optional_props,
additional_properties=None,
relative_imports=prop_data.relative_imports,
lazy_imports=prop_data.lazy_imports,
)
prop = ModelProperty(
class_info=class_info,
data=oai.Schema.model_construct(allOf=[prop1.data, prop2.data]),
roots=roots,
details=prop_details,
description=prop2.description or prop1.description,
default=None,
required=prop2.required or prop1.required,
name=name,
python_name=utils.PythonIdentifier(value=name, prefix=config.field_prefix),
example=prop2.example or prop1.example,
)
return prop
def _merge_string_with_format(prop1: Property, prop2: Property) -> Property | None | PropertyError:
"""Merge a string that has no format with a string that has a format"""
# Here we need to use the DateProperty/DateTimeProperty/FileProperty as the base so that we preserve
# its class, but keep the correct override order for merging the attributes.
if isinstance(prop1, StringProperty) and isinstance(prop2, STRING_WITH_FORMAT_TYPES):
# Use the more specific class as a base, but keep the correct override order
return _merge_common_attributes(prop2, prop1, prop2)
elif isinstance(prop2, StringProperty) and isinstance(prop1, STRING_WITH_FORMAT_TYPES):
return _merge_common_attributes(prop1, prop2)
else:
return None
def _merge_numeric(prop1: Property, prop2: Property) -> IntProperty | None | PropertyError:
"""Merge IntProperty with FloatProperty"""
if isinstance(prop1, IntProperty) and isinstance(prop2, (IntProperty, FloatProperty)):
return _merge_common_attributes(prop1, prop2)
elif isinstance(prop2, IntProperty) and isinstance(prop1, (IntProperty, FloatProperty)):
# Use the IntProperty as a base since it's more restrictive, but keep the correct override order
return _merge_common_attributes(prop2, prop1, prop2)
else:
return None
def _merge_with_enum(prop1: PropertyProtocol, prop2: PropertyProtocol) -> EnumProperty | PropertyError:
if isinstance(prop1, EnumProperty) and isinstance(prop2, EnumProperty):
# We want the narrowest validation rules that fit both, so use whichever values list is a
# subset of the other.
if _values_are_subset(prop1, prop2):
values = prop1.values
class_info = prop1.class_info
elif _values_are_subset(prop2, prop1):
values = prop2.values
class_info = prop2.class_info
else:
return PropertyError(detail="can't redefine an enum property with incompatible lists of values")
return _merge_common_attributes(evolve(prop1, values=values, class_info=class_info), prop2)
# If enum values were specified for just one of the properties, use those.
enum_prop = prop1 if isinstance(prop1, EnumProperty) else cast(EnumProperty, prop2)
non_enum_prop = prop2 if isinstance(prop1, EnumProperty) else prop1
if (isinstance(non_enum_prop, IntProperty) and enum_prop.value_type is int) or (
isinstance(non_enum_prop, StringProperty) and enum_prop.value_type is str
):
return _merge_common_attributes(enum_prop, prop1, prop2)
return PropertyError(
detail=f"can't combine enum of type {enum_prop.value_type} with {non_enum_prop.get_type_string(no_optional=True)}"
)
def _merge_with_literal_enum(prop1: PropertyProtocol, prop2: PropertyProtocol) -> LiteralEnumProperty | PropertyError:
if isinstance(prop1, LiteralEnumProperty) and isinstance(prop2, LiteralEnumProperty):
# We want the narrowest validation rules that fit both, so use whichever values list is a
# subset of the other.
if prop1.values <= prop2.values:
values = prop1.values
class_info = prop1.class_info
elif prop2.values <= prop1.values:
values = prop2.values
class_info = prop2.class_info
else:
return PropertyError(detail="can't redefine a literal enum property with incompatible lists of values")
return _merge_common_attributes(evolve(prop1, values=values, class_info=class_info), prop2)
# If enum values were specified for just one of the properties, use those.
enum_prop = prop1 if isinstance(prop1, LiteralEnumProperty) else cast(LiteralEnumProperty, prop2)
non_enum_prop = prop2 if isinstance(prop1, LiteralEnumProperty) else prop1
if (isinstance(non_enum_prop, IntProperty) and enum_prop.value_type is int) or (
isinstance(non_enum_prop, StringProperty) and enum_prop.value_type is str
):
return _merge_common_attributes(enum_prop, prop1, prop2)
return PropertyError(
detail=f"can't combine literal enum of type {enum_prop.value_type} with {non_enum_prop.get_type_string(no_optional=True)}"
)
def _merge_common_attributes(base: PropertyT, *extend_with: PropertyProtocol) -> PropertyT | PropertyError:
"""Create a new instance based on base, overriding basic attributes with values from extend_with, in order.
For "default", "description", and "example", a non-None value overrides any value from a previously
specified property. The behavior is similar to using the spread operator with dictionaries, except
that None means "not specified".
For "required", any True value overrides all other values (a property that was previously required
cannot become optional).
"""
current = base
for override in extend_with:
if override.default is not None:
override_default = current.convert_value(override.default.raw_value)
else:
override_default = None
if isinstance(override_default, PropertyError):
return override_default
current = evolve(
current, # type: ignore # can't prove that every property type is an attrs class, but it is
required=current.required or override.required,
default=override_default or current.default,
description=override.description or current.description,
example=override.example or current.example,
)
return current
def _values_are_subset(prop1: EnumProperty, prop2: EnumProperty) -> bool:
return set(prop1.values.items()) <= set(prop2.values.items())
def _model_is_extension_of(
extended_model: ModelProperty, base_model: ModelProperty, parent_name: str, config: Config
) -> bool:
def _properties_are_extension_of(extended_list: list[Property], base_list: list[Property]) -> bool:
for p2 in base_list:
if not [p1 for p1 in extended_list if _property_is_extension_of(p2, p1, parent_name, config)]:
return False
return True
return _properties_are_extension_of(
extended_model.required_properties, base_model.required_properties
) and _properties_are_extension_of(extended_model.optional_properties, base_model.optional_properties)
def _property_is_extension_of(
extended_prop: Property, base_prop: Property, parent_name: str, config: Config
) -> bool:
return base_prop.name == extended_prop.name and (
base_prop == extended_prop or merge_properties(base_prop, extended_prop, parent_name, config) == extended_prop
)