Skip to content

Commit 89877d6

Browse files
committed
fix: better support for merging properties with allOf
1 parent 3fb5fb2 commit 89877d6

File tree

5 files changed

+435
-136
lines changed

5 files changed

+435
-136
lines changed
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
2+
from collections.abc import Callable
3+
from typing import Any
4+
5+
from attr import evolve
6+
7+
from openapi_python_client.parser.properties.any import AnyProperty
8+
from openapi_python_client.parser.properties.enum_property import EnumProperty, ValueType
9+
from openapi_python_client.parser.properties.float import FloatProperty
10+
from openapi_python_client.parser.properties.int import IntProperty
11+
from openapi_python_client.parser.properties.list_property import ListProperty
12+
from openapi_python_client.parser.properties.protocol import PropertyProtocol
13+
from openapi_python_client.parser.properties.string import StringProperty
14+
15+
16+
def merge_properties(prop1: PropertyProtocol, prop2: PropertyProtocol) -> PropertyProtocol:
17+
"""Attempt to create a new property that incorporates the behavior of both.
18+
19+
This is used when merging schemas with allOf, when two schemas define a property with the same name.
20+
21+
OpenAPI defines allOf in terms of validation behavior: the input must pass the validation rules
22+
defined in all of the listed schemas. Our task here is slightly more difficult, since we must end
23+
up with a single Property object that will be used to generate a single class property in the
24+
generated code. Due to limitations of our internal model, this may not be possible for some
25+
combinations of property attributes that OpenAPI supports (for instance, we have no way to represent
26+
a string property that must match two different regexes).
27+
28+
Properties can also have attributes that do not represent validation rules, such as "description"
29+
and "example". OpenAPI does not define any overriding/aggregation rules for these in allOf. The
30+
implementation here is, assuming prop1 and prop2 are in the same order that the schemas were in the
31+
allOf, any such attributes that prop2 specifies will override the ones from prop1.
32+
33+
Any failure is thrown as a ValueError.
34+
"""
35+
if isinstance(prop1, EnumProperty) or isinstance(prop2, EnumProperty):
36+
return _merge_with_enum(prop1, prop2)
37+
38+
if prop1.__class__ == prop2.__class__:
39+
return _merge_same_type(prop1, prop2)
40+
41+
if isinstance(prop1, AnyProperty) or isinstance(prop2, AnyProperty):
42+
return _merge_with_any(prop1, prop2)
43+
44+
if _is_numeric(prop1) and _is_numeric(prop2):
45+
return _merge_numeric(prop1, prop2)
46+
47+
raise ValueError("defined with two incompatible types")
48+
49+
50+
def _is_numeric(prop: PropertyProtocol) -> bool:
51+
return isinstance(prop, IntProperty) or isinstance(prop, FloatProperty)
52+
53+
54+
def _merge_same_type(prop1: PropertyProtocol, prop2: PropertyProtocol) -> PropertyProtocol:
55+
if prop1 == prop2:
56+
# It's always OK to redefine a property with everything exactly the same
57+
return prop1
58+
59+
if isinstance(prop1, StringProperty):
60+
return _merge_string(prop1, prop2)
61+
62+
if isinstance(prop1, ListProperty):
63+
# There's no clear way to represent the intersection of two different list types. Fail in this case.
64+
if prop1.inner_property != prop2.inner_property:
65+
raise ValueError("can't redefine a list property with a different element type")
66+
67+
# For all other property types, there aren't any special attributes that affect validation, so just
68+
# apply the rules for common attributes like "description".
69+
return _merge_common_attributes(prop1, prop2)
70+
71+
72+
def _merge_string(prop1: StringProperty, prop2: StringProperty) -> StringProperty:
73+
# If max_length was specified for both, the smallest value takes precedence. If only one of them
74+
# specifies it, _combine_values will pick that one.
75+
max_length: int | None = _combine_values(prop1.max_length, prop2.max_length, lambda a, b: min([a, b]))
76+
77+
# If pattern was specified for both, we have a problem. OpenAPI has no logical objection to this;
78+
# it would just mean the value must match both of the patterns to be valid. But we have no way to
79+
# represent this in our internal model currently.
80+
pattern: str | None | ValueError = _combine_values(
81+
prop1.pattern,
82+
prop2.pattern,
83+
lambda a, b: ValueError("specified two different regex patterns")
84+
)
85+
if isinstance(pattern, ValueError):
86+
raise pattern
87+
88+
return _merge_common_attributes(evolve(prop1, max_length=max_length, pattern=pattern), prop2)
89+
90+
91+
def _merge_numeric(prop1: PropertyProtocol, prop2: PropertyProtocol) -> IntProperty:
92+
# Here, one of the properties was defined as "int" and the other was just a general number (which
93+
# we call FloatProperty). "Must be integer" is the stricter validation rule, so the result must be
94+
# an IntProperty.
95+
int_prop = prop1 if isinstance(prop1, IntProperty) else prop2
96+
result = _merge_common_attributes(int_prop, prop1, prop2)
97+
if result.default is not None:
98+
if isinstance(result.default, float) and not result.default.is_integer():
99+
raise ValueError(f"default value {result.default} is not valid for an integer property")
100+
return result
101+
102+
103+
def _merge_with_any(prop1: PropertyProtocol, prop2: PropertyProtocol) -> PropertyProtocol:
104+
# AnyProperty implies no validation rules for a value, so merging it with any other type just means
105+
# we should use the validation rules for the other type and the result should not be an AnyProperty.
106+
non_any_prop = prop2 if isinstance(prop1, AnyProperty) else prop1
107+
return _merge_common_attributes(non_any_prop, prop1, prop2)
108+
109+
110+
def _merge_with_enum(prop1: PropertyProtocol, prop2: PropertyProtocol) -> EnumProperty:
111+
if isinstance(prop1, EnumProperty) and isinstance(prop2, EnumProperty):
112+
# We want the narrowest validation rules that fit both, so use whichever values list is a
113+
# subset of the other.
114+
values: dict[str, ValueType]
115+
if _values_are_subset(prop1, prop2):
116+
values = prop1.values
117+
elif _values_are_subset(prop2, prop1):
118+
values = prop2.values
119+
else:
120+
raise ValueError("can't redefine an enum property with incompatible lists of values")
121+
return _merge_common_attributes(evolve(prop1, values=values), prop2)
122+
123+
# If enum values were specified for just one of the properties, use those.
124+
enum_prop = prop1 if isinstance(prop1, EnumProperty) else prop2
125+
non_enum_prop = prop2 if isinstance(prop1, EnumProperty) else prop1
126+
if (
127+
(isinstance(non_enum_prop, IntProperty) and enum_prop.value_type is int) or
128+
(isinstance(non_enum_prop, StringProperty) and enum_prop.value_type is str)
129+
):
130+
return _merge_common_attributes(enum_prop, prop1, prop2)
131+
raise ValueError("defined with two incompatible types")
132+
133+
134+
def _merge_common_attributes(base: PropertyProtocol, *extend_with: PropertyProtocol) -> PropertyProtocol:
135+
"""Create a new instance based on base, overriding basic attributes with values from extend_with, in order.
136+
137+
For "default", "description", and "example", a non-None value overrides any value from a previously
138+
specified property. The behavior is similar to using the spread operator with dictionaries, except
139+
that None means "not specified".
140+
141+
For "required", any True value overrides all other values (a property that was previously required
142+
cannot become optional).
143+
"""
144+
current = base
145+
for override in extend_with:
146+
current = evolve(
147+
current,
148+
required=current.required or override.required,
149+
default = override.default or current.default,
150+
description = override.description or current.description,
151+
example = override.example or current.example,
152+
)
153+
return current
154+
155+
156+
def _values_are_subset(prop1: EnumProperty, prop2: EnumProperty) -> bool:
157+
return set(prop1.values.items()) <= set(prop2.values.items())
158+
159+
160+
def _combine_values(value1: Any, value2: Any, combinator: Callable) -> Any:
161+
if value1 == value2:
162+
return value1
163+
if value1 is None:
164+
return value2
165+
if value2 is None:
166+
return value1
167+
return combinator(value1, value2)

openapi_python_client/parser/properties/model_property.py

Lines changed: 12 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55

66
from attrs import define, evolve
77

8+
from openapi_python_client.parser.properties.merge_properties import merge_properties
9+
810
from ... import Config, utils
911
from ... import schema as oai
1012
from ...utils import PythonIdentifier
1113
from ..errors import ParseError, PropertyError
1214
from .any import AnyProperty
13-
from .enum_property import EnumProperty
1415
from .protocol import PropertyProtocol, Value
1516
from .schemas import Class, ReferencePath, Schemas, parse_reference_path
1617

@@ -216,59 +217,6 @@ def get_type_string(
216217
from .property import Property # noqa: E402
217218

218219

219-
def _values_are_subset(first: EnumProperty, second: EnumProperty) -> bool:
220-
return set(first.values.items()) <= set(second.values.items())
221-
222-
223-
def _types_are_subset(first: EnumProperty, second: Property) -> bool:
224-
from . import IntProperty, StringProperty
225-
226-
if first.value_type is int and isinstance(second, IntProperty):
227-
return True
228-
if first.value_type is str and isinstance(second, StringProperty):
229-
return True
230-
return False
231-
232-
233-
def _enum_subset(first: Property, second: Property) -> EnumProperty | None:
234-
"""Return the EnumProperty that is the subset of the other, if possible."""
235-
236-
if isinstance(first, EnumProperty):
237-
if isinstance(second, EnumProperty):
238-
if _values_are_subset(first, second):
239-
return first
240-
if _values_are_subset(second, first):
241-
return second
242-
return None
243-
return first if _types_are_subset(first, second) else None
244-
245-
if isinstance(second, EnumProperty) and _types_are_subset(second, first):
246-
return second
247-
return None
248-
249-
250-
def _merge_properties(first: Property, second: Property) -> Property | PropertyError:
251-
required = first.required or second.required
252-
253-
err = None
254-
255-
if first.__class__ == second.__class__:
256-
first = evolve(first, required=required)
257-
second = evolve(second, required=required)
258-
if first == second:
259-
return first
260-
err = PropertyError(header="Cannot merge properties", detail="Properties has conflicting values")
261-
262-
enum_subset = _enum_subset(first, second)
263-
if enum_subset is not None:
264-
return evolve(enum_subset, required=required)
265-
266-
return err or PropertyError(
267-
header="Cannot merge properties",
268-
detail=f"{first.__class__}, {second.__class__}Properties have incompatible types",
269-
)
270-
271-
272220
def _resolve_naming_conflict(first: Property, second: Property, config: Config) -> PropertyError | None:
273221
first.set_python_name(first.name, config=config, skip_snake_case=True)
274222
second.set_python_name(second.name, config=config, skip_snake_case=True)
@@ -307,23 +255,24 @@ def _add_if_no_conflict(new_prop: Property) -> PropertyError | None:
307255
nonlocal properties
308256

309257
name_conflict = properties.get(new_prop.name)
310-
merged_prop_or_error = _merge_properties(name_conflict, new_prop) if name_conflict else new_prop
311-
if isinstance(merged_prop_or_error, PropertyError):
312-
merged_prop_or_error.header = (
313-
f"Found conflicting properties named {new_prop.name} when creating {class_name}"
258+
try:
259+
merged_prop = merge_properties(name_conflict, new_prop) if name_conflict else new_prop
260+
except ValueError as e:
261+
return PropertyError(
262+
header=f"Found conflicting properties named {new_prop.name} when creating {class_name}",
263+
detail=str(e),
314264
)
315-
return merged_prop_or_error
316265

317266
for other_prop in properties.values():
318-
if other_prop.name == merged_prop_or_error.name:
267+
if other_prop.name == merged_prop.name:
319268
continue # Same property, probably just got merged
320-
if other_prop.python_name != merged_prop_or_error.python_name:
269+
if other_prop.python_name != merged_prop.python_name:
321270
continue
322-
naming_error = _resolve_naming_conflict(merged_prop_or_error, other_prop, config)
271+
naming_error = _resolve_naming_conflict(merged_prop, other_prop, config)
323272
if naming_error is not None:
324273
return naming_error
325274

326-
properties[merged_prop_or_error.name] = merged_prop_or_error
275+
properties[merged_prop.name] = merged_prop
327276
return None
328277

329278
unprocessed_props = data.properties or {}

0 commit comments

Comments
 (0)