Skip to content

Commit c9994db

Browse files
committed
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.1` up to par with `main` for the features we implemented in our fork (dropping our `Unset` implementation for theirs)
1 parent b12b4e5 commit c9994db

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
)
@@ -278,6 +291,7 @@ def build_model_property(
278291

279292
prop = ModelProperty(
280293
reference=ref,
294+
references=references,
281295
required_properties=required_properties,
282296
optional_properties=optional_properties,
283297
relative_imports=relative_imports,
@@ -530,6 +544,16 @@ def build_schemas(*, components: Dict[str, Union[oai.Reference, oai.Schema]]) ->
530544
schemas = schemas_or_err
531545
processing = True # We made some progress this round, do another after it's done
532546
to_process = next_round
533-
schemas.errors.extend(errors)
534547

548+
resolve_errors: List[PropertyError] = []
549+
models = list(schemas.models.values())
550+
for model in models:
551+
schemas_or_err = model.resolve_references(components=components, schemas=schemas)
552+
if isinstance(schemas_or_err, PropertyError):
553+
resolve_errors.append(schemas_or_err)
554+
else:
555+
schemas = schemas_or_err
556+
557+
schemas.errors.extend(errors)
558+
schemas.errors.extend(resolve_errors)
535559
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,17 +1,25 @@
1-
from typing import ClassVar, List, Set, Union
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
@@ -20,6 +28,49 @@ class ModelProperty(Property):
2028

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

31+
def resolve_references(
32+
self, components: Dict[str, Union[oai.Reference, oai.Schema]], schemas: Schemas
33+
) -> Union[Schemas, PropertyError]:
34+
from ..properties import property_from_data
35+
36+
required_set = set()
37+
props = {}
38+
while self.references:
39+
reference = self.references.pop()
40+
source_name = Reference.from_ref(reference.ref).class_name
41+
referenced_prop = components[source_name]
42+
assert isinstance(referenced_prop, oai.Schema)
43+
for p, val in (referenced_prop.properties or {}).items():
44+
props[p] = (val, source_name)
45+
for sub_prop in referenced_prop.allOf or []:
46+
if isinstance(sub_prop, oai.Reference):
47+
self.references.append(sub_prop)
48+
else:
49+
for p, val in (sub_prop.properties or {}).items():
50+
props[p] = (val, source_name)
51+
if isinstance(referenced_prop.required, Iterable):
52+
for sub_prop_name in referenced_prop.required:
53+
required_set.add(sub_prop_name)
54+
55+
for key, (value, source_name) in (props or {}).items():
56+
required = key in required_set
57+
prop, schemas = property_from_data(
58+
name=key, required=required, data=value, schemas=schemas, parent_name=source_name
59+
)
60+
if isinstance(prop, PropertyError):
61+
return prop
62+
if required:
63+
self.required_properties.append(prop)
64+
# Remove the optional version
65+
new_optional_props = [op for op in self.optional_properties if op.name != prop.name]
66+
self.optional_properties.clear()
67+
self.optional_properties.extend(new_optional_props)
68+
elif not any(ep for ep in (self.optional_properties + self.required_properties) if ep.name == prop.name):
69+
self.optional_properties.append(prop)
70+
self.relative_imports.update(prop.get_imports(prefix=".."))
71+
72+
return schemas
73+
2374
def get_type_string(self, no_optional: bool = False) -> str:
2475
""" Get a string representation of type that should be used when declaring this property """
2576
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="",
@@ -600,6 +601,7 @@ def test_property_from_data_ref_model(self):
600601
nullable=False,
601602
default=None,
602603
reference=Reference(class_name=class_name, module_name="my_model"),
604+
references=[],
603605
required_properties=[],
604606
optional_properties=[],
605607
description="",
@@ -986,19 +988,25 @@ def test__string_based_property_unsupported_format(self, mocker):
986988
def test_build_schemas(mocker):
987989
build_model_property = mocker.patch(f"{MODULE_NAME}.build_model_property")
988990
in_data = {"1": mocker.MagicMock(enum=None), "2": mocker.MagicMock(enum=None), "3": mocker.MagicMock(enum=None)}
991+
989992
model_1 = mocker.MagicMock()
990993
schemas_1 = mocker.MagicMock()
991994
model_2 = mocker.MagicMock()
992995
schemas_2 = mocker.MagicMock(errors=[])
993-
error = PropertyError()
996+
schemas_2.models = {"1": model_1, "2": model_2}
997+
error_1 = PropertyError()
994998
schemas_3 = mocker.MagicMock()
999+
schemas_4 = mocker.MagicMock(errors=[])
1000+
model_1.resolve_references.return_value = schemas_4
1001+
error_2 = PropertyError()
1002+
model_2.resolve_references.return_value = error_2
9951003

9961004
# This loops through one for each, then again to retry the error
9971005
build_model_property.side_effect = [
9981006
(model_1, schemas_1),
9991007
(model_2, schemas_2),
1000-
(error, schemas_3),
1001-
(error, schemas_3),
1008+
(error_1, schemas_3),
1009+
(error_1, schemas_3),
10021010
]
10031011

10041012
from openapi_python_client.parser.properties import Schemas, build_schemas
@@ -1014,8 +1022,12 @@ def test_build_schemas(mocker):
10141022
]
10151023
)
10161024
# schemas_3 was the last to come back from build_model_property, but it should be ignored because it's an error
1017-
assert result == schemas_2
1018-
assert result.errors == [error]
1025+
model_1.resolve_references.assert_called_once_with(components=in_data, schemas=schemas_2)
1026+
# schemas_4 came from resolving model_1
1027+
model_2.resolve_references.assert_called_once_with(components=in_data, schemas=schemas_4)
1028+
# resolving model_2 resulted in err, so no schemas_5
1029+
assert result == schemas_4
1030+
assert result.errors == [error_1, error_2]
10191031

10201032

10211033
def test_build_parse_error_on_reference():
@@ -1089,6 +1101,7 @@ def test_build_model_property(additional_properties_schema, expected_additional_
10891101
nullable=False,
10901102
default=None,
10911103
reference=Reference(class_name="ParentMyModel", module_name="parent_my_model"),
1104+
references=[],
10921105
required_properties=[StringProperty(name="req", required=True, nullable=False, default=None)],
10931106
optional_properties=[DateTimeProperty(name="opt", required=False, nullable=False, default=None)],
10941107
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=[],
@@ -42,6 +43,7 @@ def test_get_imports():
4243
nullable=True,
4344
default=None,
4445
reference=Reference(class_name="MyClass", module_name="my_module"),
46+
references=[],
4547
description="",
4648
optional_properties=[],
4749
required_properties=[],
@@ -57,3 +59,67 @@ def test_get_imports():
5759
"from typing import Dict",
5860
"from typing import cast",
5961
}
62+
63+
64+
def test_resolve_references(mocker):
65+
import openapi_python_client.schema as oai
66+
from openapi_python_client.parser.properties import build_model_property
67+
68+
schemas = {
69+
"RefA": oai.Schema.construct(
70+
title=mocker.MagicMock(),
71+
description=mocker.MagicMock(),
72+
required=["String"],
73+
properties={
74+
"String": oai.Schema.construct(type="string"),
75+
"Enum": oai.Schema.construct(type="string", enum=["aValue"]),
76+
"DateTime": oai.Schema.construct(type="string", format="date-time"),
77+
},
78+
),
79+
"RefB": oai.Schema.construct(
80+
title=mocker.MagicMock(),
81+
description=mocker.MagicMock(),
82+
required=["DateTime"],
83+
properties={
84+
"Int": oai.Schema.construct(type="integer"),
85+
"DateTime": oai.Schema.construct(type="string", format="date-time"),
86+
"Float": oai.Schema.construct(type="number", format="float"),
87+
},
88+
),
89+
# Intentionally no properties defined
90+
"RefC": oai.Schema.construct(
91+
title=mocker.MagicMock(),
92+
description=mocker.MagicMock(),
93+
),
94+
}
95+
96+
model_schema = oai.Schema.construct(
97+
allOf=[
98+
oai.Reference.construct(ref="#/components/schemas/RefA"),
99+
oai.Reference.construct(ref="#/components/schemas/RefB"),
100+
oai.Reference.construct(ref="#/components/schemas/RefC"),
101+
oai.Schema.construct(
102+
title=mocker.MagicMock(),
103+
description=mocker.MagicMock(),
104+
required=["Float"],
105+
properties={
106+
"String": oai.Schema.construct(type="string"),
107+
"Float": oai.Schema.construct(type="number", format="float"),
108+
},
109+
),
110+
]
111+
)
112+
113+
components = {**schemas, "Model": model_schema}
114+
115+
from openapi_python_client.parser.properties import Schemas
116+
117+
schemas_holder = Schemas()
118+
model, schemas_holder = build_model_property(
119+
data=model_schema, name="Model", required=True, schemas=schemas_holder, parent_name=None
120+
)
121+
model.resolve_references(components, schemas_holder)
122+
assert sorted(p.name for p in model.required_properties) == ["DateTime", "Float", "String"]
123+
assert all(p.required for p in model.required_properties)
124+
assert sorted(p.name for p in model.optional_properties) == ["Enum", "Int"]
125+
assert all(not p.required for p in model.optional_properties)

0 commit comments

Comments
 (0)