Skip to content

Commit 782bbd0

Browse files
authored
feat: Add allOf support for model definitions BNCH-18722 (#12)
Collapses the child elements into one, without class heirarchy, mixins, etc This is a replaying of 2670d11 (first implementation) and 9f5b95a (a bugfix) onto the new `main-v.0.7.0`, modified for the refactored upstream. This should bring `main-v.0.7.0` up to par with `main` for the features we implemented in our fork (dropping our `Unset` implementation for theirs)
1 parent a091bdb commit 782bbd0

File tree

4 files changed

+163
-9
lines changed

4 files changed

+163
-9
lines changed

openapi_python_client/parser/properties/__init__.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,14 +236,27 @@ def build_model_property(
236236
required_properties: List[Property] = []
237237
optional_properties: List[Property] = []
238238
relative_imports: Set[str] = set()
239+
references: List[oai.Reference] = []
239240

240241
class_name = data.title or name
241242
if parent_name:
242243
class_name = f"{utils.pascal_case(parent_name)}{utils.pascal_case(class_name)}"
243244
ref = Reference.from_ref(class_name)
244245

245-
for key, value in (data.properties or {}).items():
246+
all_props = data.properties or {}
247+
if not isinstance(data, oai.Reference) and data.allOf:
248+
for sub_prop in data.allOf:
249+
if isinstance(sub_prop, oai.Reference):
250+
references += [sub_prop]
251+
else:
252+
all_props.update(sub_prop.properties or {})
253+
required_set.update(sub_prop.required or [])
254+
255+
for key, value in all_props.items():
246256
prop_required = key in required_set
257+
if not isinstance(value, oai.Reference) and value.allOf:
258+
# resolved later
259+
continue
247260
prop, schemas = property_from_data(
248261
name=key, required=prop_required, data=value, schemas=schemas, parent_name=class_name
249262
)
@@ -257,6 +270,7 @@ def build_model_property(
257270

258271
prop = ModelProperty(
259272
reference=ref,
273+
references=references,
260274
required_properties=required_properties,
261275
optional_properties=optional_properties,
262276
relative_imports=relative_imports,
@@ -508,6 +522,16 @@ def build_schemas(*, components: Dict[str, Union[oai.Reference, oai.Schema]]) ->
508522
schemas = schemas_or_err
509523
processing = True # We made some progress this round, do another after it's done
510524
to_process = next_round
511-
schemas.errors.extend(errors)
512525

526+
resolve_errors: List[PropertyError] = []
527+
models = list(schemas.models.values())
528+
for model in models:
529+
schemas_or_err = model.resolve_references(components=components, schemas=schemas)
530+
if isinstance(schemas_or_err, PropertyError):
531+
resolve_errors.append(schemas_or_err)
532+
else:
533+
schemas = schemas_or_err
534+
535+
schemas.errors.extend(errors)
536+
schemas.errors.extend(resolve_errors)
513537
return schemas

openapi_python_client/parser/properties/model_property.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,75 @@
1-
from typing import ClassVar, List, Set
1+
from __future__ import annotations
2+
3+
from collections.abc import Iterable
4+
from typing import TYPE_CHECKING, ClassVar, Dict, List, Set, Union
25

36
import attr
47

8+
from ... import schema as oai
9+
from ..errors import PropertyError
510
from ..reference import Reference
611
from .property import Property
712

13+
if TYPE_CHECKING:
14+
from .schemas import Schemas
15+
816

917
@attr.s(auto_attribs=True, frozen=True)
1018
class ModelProperty(Property):
1119
""" A property which refers to another Schema """
1220

1321
reference: Reference
14-
22+
references: List[oai.Reference]
1523
required_properties: List[Property]
1624
optional_properties: List[Property]
1725
description: str
1826
relative_imports: Set[str]
1927

2028
template: ClassVar[str] = "model_property.pyi"
2129

30+
def resolve_references(
31+
self, components: Dict[str, Union[oai.Reference, oai.Schema]], schemas: Schemas
32+
) -> Union[Schemas, PropertyError]:
33+
from ..properties import property_from_data
34+
35+
required_set = set()
36+
props = {}
37+
while self.references:
38+
reference = self.references.pop()
39+
source_name = Reference.from_ref(reference.ref).class_name
40+
referenced_prop = components[source_name]
41+
assert isinstance(referenced_prop, oai.Schema)
42+
for p, val in (referenced_prop.properties or {}).items():
43+
props[p] = (val, source_name)
44+
for sub_prop in referenced_prop.allOf or []:
45+
if isinstance(sub_prop, oai.Reference):
46+
self.references.append(sub_prop)
47+
else:
48+
for p, val in (sub_prop.properties or {}).items():
49+
props[p] = (val, source_name)
50+
if isinstance(referenced_prop.required, Iterable):
51+
for sub_prop_name in referenced_prop.required:
52+
required_set.add(sub_prop_name)
53+
54+
for key, (value, source_name) in (props or {}).items():
55+
required = key in required_set
56+
prop, schemas = property_from_data(
57+
name=key, required=required, data=value, schemas=schemas, parent_name=source_name
58+
)
59+
if isinstance(prop, PropertyError):
60+
return prop
61+
if required:
62+
self.required_properties.append(prop)
63+
# Remove the optional version
64+
new_optional_props = [op for op in self.optional_properties if op.name != prop.name]
65+
self.optional_properties.clear()
66+
self.optional_properties.extend(new_optional_props)
67+
elif not any(ep for ep in (self.optional_properties + self.required_properties) if ep.name == prop.name):
68+
self.optional_properties.append(prop)
69+
self.relative_imports.update(prop.get_imports(prefix=".."))
70+
71+
return schemas
72+
2273
def get_type_string(self, no_optional: bool = False) -> str:
2374
""" Get a string representation of type that should be used when declaring this property """
2475
type_string = self.reference.class_name

tests/test_parser/test_properties/test_init.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,7 @@ def test_property_from_data_ref_model(self):
584584
nullable=False,
585585
default=None,
586586
reference=Reference(class_name=class_name, module_name="my_model"),
587+
references=[],
587588
required_properties=[],
588589
optional_properties=[],
589590
description="",
@@ -599,6 +600,7 @@ def test_property_from_data_ref_model(self):
599600
nullable=False,
600601
default=None,
601602
reference=Reference(class_name=class_name, module_name="my_model"),
603+
references=[],
602604
required_properties=[],
603605
optional_properties=[],
604606
description="",
@@ -984,19 +986,25 @@ def test__string_based_property_unsupported_format(self, mocker):
984986
def test_build_schemas(mocker):
985987
build_model_property = mocker.patch(f"{MODULE_NAME}.build_model_property")
986988
in_data = {"1": mocker.MagicMock(enum=None), "2": mocker.MagicMock(enum=None), "3": mocker.MagicMock(enum=None)}
989+
987990
model_1 = mocker.MagicMock()
988991
schemas_1 = mocker.MagicMock()
989992
model_2 = mocker.MagicMock()
990993
schemas_2 = mocker.MagicMock(errors=[])
991-
error = PropertyError()
994+
schemas_2.models = {"1": model_1, "2": model_2}
995+
error_1 = PropertyError()
992996
schemas_3 = mocker.MagicMock()
997+
schemas_4 = mocker.MagicMock(errors=[])
998+
model_1.resolve_references.return_value = schemas_4
999+
error_2 = PropertyError()
1000+
model_2.resolve_references.return_value = error_2
9931001

9941002
# This loops through one for each, then again to retry the error
9951003
build_model_property.side_effect = [
9961004
(model_1, schemas_1),
9971005
(model_2, schemas_2),
998-
(error, schemas_3),
999-
(error, schemas_3),
1006+
(error_1, schemas_3),
1007+
(error_1, schemas_3),
10001008
]
10011009

10021010
from openapi_python_client.parser.properties import Schemas, build_schemas
@@ -1012,8 +1020,12 @@ def test_build_schemas(mocker):
10121020
]
10131021
)
10141022
# schemas_3 was the last to come back from build_model_property, but it should be ignored because it's an error
1015-
assert result == schemas_2
1016-
assert result.errors == [error]
1023+
model_1.resolve_references.assert_called_once_with(components=in_data, schemas=schemas_2)
1024+
# schemas_4 came from resolving model_1
1025+
model_2.resolve_references.assert_called_once_with(components=in_data, schemas=schemas_4)
1026+
# resolving model_2 resulted in err, so no schemas_5
1027+
assert result == schemas_4
1028+
assert result.errors == [error_1, error_2]
10171029

10181030

10191031
def test_build_parse_error_on_reference():
@@ -1073,6 +1085,7 @@ def test_build_model_property():
10731085
nullable=False,
10741086
default=None,
10751087
reference=Reference(class_name="ParentMyModel", module_name="parent_my_model"),
1088+
references=[],
10761089
required_properties=[StringProperty(name="req", required=True, nullable=False, default=None)],
10771090
optional_properties=[DateTimeProperty(name="opt", required=False, nullable=False, default=None)],
10781091
description=data.description,

tests/test_parser/test_properties/test_model_property.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ def test_get_type_string(no_optional, nullable, required, expected):
2323
nullable=nullable,
2424
default=None,
2525
reference=Reference(class_name="MyClass", module_name="my_module"),
26+
references=[],
2627
description="",
2728
optional_properties=[],
2829
required_properties=[],
@@ -41,6 +42,7 @@ def test_get_imports():
4142
nullable=True,
4243
default=None,
4344
reference=Reference(class_name="MyClass", module_name="my_module"),
45+
references=[],
4446
description="",
4547
optional_properties=[],
4648
required_properties=[],
@@ -55,3 +57,67 @@ def test_get_imports():
5557
"from typing import Dict",
5658
"from typing import cast",
5759
}
60+
61+
62+
def test_resolve_references(mocker):
63+
import openapi_python_client.schema as oai
64+
from openapi_python_client.parser.properties import build_model_property
65+
66+
schemas = {
67+
"RefA": oai.Schema.construct(
68+
title=mocker.MagicMock(),
69+
description=mocker.MagicMock(),
70+
required=["String"],
71+
properties={
72+
"String": oai.Schema.construct(type="string"),
73+
"Enum": oai.Schema.construct(type="string", enum=["aValue"]),
74+
"DateTime": oai.Schema.construct(type="string", format="date-time"),
75+
},
76+
),
77+
"RefB": oai.Schema.construct(
78+
title=mocker.MagicMock(),
79+
description=mocker.MagicMock(),
80+
required=["DateTime"],
81+
properties={
82+
"Int": oai.Schema.construct(type="integer"),
83+
"DateTime": oai.Schema.construct(type="string", format="date-time"),
84+
"Float": oai.Schema.construct(type="number", format="float"),
85+
},
86+
),
87+
# Intentionally no properties defined
88+
"RefC": oai.Schema.construct(
89+
title=mocker.MagicMock(),
90+
description=mocker.MagicMock(),
91+
),
92+
}
93+
94+
model_schema = oai.Schema.construct(
95+
allOf=[
96+
oai.Reference.construct(ref="#/components/schemas/RefA"),
97+
oai.Reference.construct(ref="#/components/schemas/RefB"),
98+
oai.Reference.construct(ref="#/components/schemas/RefC"),
99+
oai.Schema.construct(
100+
title=mocker.MagicMock(),
101+
description=mocker.MagicMock(),
102+
required=["Float"],
103+
properties={
104+
"String": oai.Schema.construct(type="string"),
105+
"Float": oai.Schema.construct(type="number", format="float"),
106+
},
107+
),
108+
]
109+
)
110+
111+
components = {**schemas, "Model": model_schema}
112+
113+
from openapi_python_client.parser.properties import Schemas
114+
115+
schemas_holder = Schemas()
116+
model, schemas_holder = build_model_property(
117+
data=model_schema, name="Model", required=True, schemas=schemas_holder, parent_name=None
118+
)
119+
model.resolve_references(components, schemas_holder)
120+
assert sorted(p.name for p in model.required_properties) == ["DateTime", "Float", "String"]
121+
assert all(p.required for p in model.required_properties)
122+
assert sorted(p.name for p in model.optional_properties) == ["Enum", "Int"]
123+
assert all(not p.required for p in model.optional_properties)

0 commit comments

Comments
 (0)