Skip to content

Commit f01c91c

Browse files
committed
parser / property / add recursive reference resoltion
1 parent d935a92 commit f01c91c

File tree

5 files changed

+142
-24
lines changed

5 files changed

+142
-24
lines changed

openapi_python_client/parser/errors.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from dataclasses import dataclass
22
from enum import Enum
3-
from typing import Optional
3+
from typing import Any, Optional
44

55
__all__ = ["ErrorLevel", "GeneratorError", "ParseError", "PropertyError", "ValidationError"]
66

@@ -39,5 +39,12 @@ class PropertyError(ParseError):
3939
header = "Problem creating a Property: "
4040

4141

42+
@dataclass
43+
class RecursiveReferenceInterupt(PropertyError):
44+
"""Error raised when a property have an recursive reference to itself"""
45+
46+
schemas: Optional[Any] = None # TODO: shall not use Any here, shall be Schemas, to fix later
47+
48+
4249
class ValidationError(Exception):
4350
pass
Binary file not shown.

openapi_python_client/parser/properties/__init__.py

+85-9
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@
1717
from ... import Config
1818
from ... import schema as oai
1919
from ... import utils
20-
from ..errors import ParseError, PropertyError, ValidationError
20+
from ..errors import ParseError, PropertyError, RecursiveReferenceInterupt, ValidationError
2121
from .converter import convert, convert_chain
2222
from .enum_property import EnumProperty
2323
from .model_property import ModelProperty, build_model_property
2424
from .property import Property
25-
from .schemas import Class, Schemas, parse_reference_path, update_schemas_with
25+
from .schemas import Class, Schemas, _Holder, _ReferencePath, parse_reference_path, update_schemas_with
2626

2727

2828
@attr.s(auto_attribs=True, frozen=True)
@@ -34,6 +34,59 @@ class NoneProperty(Property):
3434
template: ClassVar[Optional[str]] = "none_property.py.jinja"
3535

3636

37+
@attr.s(auto_attribs=True, frozen=True)
38+
class LazySelfReferenceProperty(Property):
39+
"""A property used to resolve recursive reference.
40+
It proxyfy the required method call to its binded Property owner
41+
"""
42+
43+
owner: _Holder[Union[ModelProperty, EnumProperty, RecursiveReferenceInterupt]]
44+
_resolved: bool = False
45+
46+
def get_base_type_string(self) -> str:
47+
self._ensure_resolved()
48+
49+
prop = self.owner.data
50+
assert isinstance(prop, Property)
51+
return prop.get_base_type_string()
52+
53+
def get_base_json_type_string(self) -> str:
54+
self._ensure_resolved()
55+
56+
prop = self.owner.data
57+
assert isinstance(prop, Property)
58+
return prop.get_base_json_type_string()
59+
60+
def get_type_string(self, no_optional: bool = False, json: bool = False) -> str:
61+
self._ensure_resolved()
62+
63+
prop = self.owner.data
64+
assert isinstance(prop, Property)
65+
return prop.get_type_string(no_optional, json)
66+
67+
def get_instance_type_string(self) -> str:
68+
self._ensure_resolved()
69+
return super().get_instance_type_string()
70+
71+
def to_string(self) -> str:
72+
self._ensure_resolved()
73+
74+
if not self.required:
75+
return f"{self.python_name}: Union[Unset, {self.get_type_string()}] = UNSET"
76+
else:
77+
return f"{self.python_name}: {self.get_type_string()}"
78+
79+
def _ensure_resolved(self) -> None:
80+
if self._resolved:
81+
return
82+
83+
if not isinstance(self.owner.data, Property):
84+
raise RuntimeError(f"LazySelfReferenceProperty {self.name} owner shall have been resolved.")
85+
else:
86+
object.__setattr__(self, "_resolved", True)
87+
object.__setattr__(self, "nullable", self.owner.data.nullable)
88+
89+
3790
@attr.s(auto_attribs=True, frozen=True)
3891
class StringProperty(Property):
3992
"""A property of type str"""
@@ -411,11 +464,18 @@ def _property_from_ref(
411464
ref_path = parse_reference_path(data.ref)
412465
if isinstance(ref_path, ParseError):
413466
return PropertyError(data=data, detail=ref_path.detail), schemas
467+
414468
existing = schemas.classes_by_reference.get(ref_path)
415-
if not existing:
469+
if not existing or not existing.data:
416470
return PropertyError(data=data, detail="Could not find reference in parsed models or enums"), schemas
417471

418-
prop = attr.evolve(existing, required=required, name=name)
472+
if isinstance(existing.data, RecursiveReferenceInterupt):
473+
return (
474+
LazySelfReferenceProperty(required=required, name=name, nullable=False, default=None, owner=existing),
475+
schemas,
476+
)
477+
478+
prop = attr.evolve(existing.data, required=required, name=name)
419479
if parent:
420480
prop = attr.evolve(prop, nullable=parent.nullable)
421481
if isinstance(prop, EnumProperty):
@@ -551,28 +611,44 @@ def build_schemas(
551611
to_process: Iterable[Tuple[str, Union[oai.Reference, oai.Schema]]] = components.items()
552612
still_making_progress = True
553613
errors: List[PropertyError] = []
554-
614+
recursive_references_waiting_reprocess: Dict[str, Union[oai.Reference, oai.Schema]] = dict()
615+
visited: Set[_ReferencePath] = set()
616+
depth = 0
555617
# References could have forward References so keep going as long as we are making progress
556618
while still_making_progress:
557619
still_making_progress = False
558620
errors = []
559621
next_round = []
622+
560623
# Only accumulate errors from the last round, since we might fix some along the way
561624
for name, data in to_process:
562625
ref_path = parse_reference_path(f"#/components/schemas/{name}")
563626
if isinstance(ref_path, ParseError):
564627
schemas.errors.append(PropertyError(detail=ref_path.detail, data=data))
565628
continue
566629

567-
schemas_or_err = update_schemas_with(ref_path=ref_path, data=data, schemas=schemas, config=config)
630+
visited.add(ref_path)
631+
schemas_or_err = update_schemas_with(
632+
ref_path=ref_path, data=data, schemas=schemas, visited=visited, config=config
633+
)
568634
if isinstance(schemas_or_err, PropertyError):
569-
next_round.append((name, data))
570-
errors.append(schemas_or_err)
571-
continue
635+
if isinstance(schemas_or_err, RecursiveReferenceInterupt):
636+
up_schemas = schemas_or_err.schemas
637+
assert isinstance(up_schemas, Schemas) # TODO fix typedef in RecursiveReferenceInterupt
638+
schemas_or_err = up_schemas
639+
recursive_references_waiting_reprocess[name] = data
640+
else:
641+
next_round.append((name, data))
642+
errors.append(schemas_or_err)
643+
continue
572644

573645
schemas = schemas_or_err
574646
still_making_progress = True
647+
depth += 1
575648
to_process = next_round
576649

650+
if len(recursive_references_waiting_reprocess.keys()):
651+
schemas = build_schemas(components=recursive_references_waiting_reprocess, schemas=schemas, config=config)
652+
577653
schemas.errors.extend(errors)
578654
return schemas

openapi_python_client/parser/properties/model_property.py

+10-2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
from .schemas import Class, Schemas, parse_reference_path
1212

1313

14+
@attr.s(auto_attribs=True, frozen=True)
15+
class RecusiveProperty(Property):
16+
pass
17+
18+
1419
@attr.s(auto_attribs=True, frozen=True)
1520
class ModelProperty(Property):
1621
"""A property which refers to another Schema"""
@@ -93,9 +98,12 @@ def _check_existing(prop: Property) -> Union[Property, PropertyError]:
9398
ref_path = parse_reference_path(sub_prop.ref)
9499
if isinstance(ref_path, ParseError):
95100
return PropertyError(detail=ref_path.detail, data=sub_prop)
96-
sub_model = schemas.classes_by_reference.get(ref_path)
97-
if sub_model is None:
101+
102+
sub_model_ref = schemas.classes_by_reference.get(ref_path)
103+
if sub_model_ref is None or not isinstance(sub_model_ref.data, Property):
98104
return PropertyError(f"Reference {sub_prop.ref} not found")
105+
106+
sub_model = sub_model_ref.data
99107
if not isinstance(sub_model, ModelProperty):
100108
return PropertyError("Cannot take allOf a non-object")
101109
for prop in chain(sub_model.required_properties, sub_model.optional_properties):

openapi_python_client/parser/properties/schemas.py

+39-12
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
1-
__all__ = ["Class", "Schemas", "parse_reference_path", "update_schemas_with"]
1+
__all__ = ["Class", "Schemas", "parse_reference_path", "update_schemas_with", "_ReferencePath"]
22

3-
from typing import TYPE_CHECKING, Dict, List, NewType, Union, cast
3+
from typing import TYPE_CHECKING, Dict, Generic, List, NewType, Optional, Set, TypeVar, Union, cast
44
from urllib.parse import urlparse
55

66
import attr
77

88
from ... import Config
99
from ... import schema as oai
1010
from ... import utils
11-
from ..errors import ParseError, PropertyError
11+
from ..errors import ParseError, PropertyError, RecursiveReferenceInterupt
1212

1313
if TYPE_CHECKING: # pragma: no cover
1414
from .property import Property
1515
else:
1616
Property = "Property"
1717

18-
18+
T = TypeVar("T")
1919
_ReferencePath = NewType("_ReferencePath", str)
2020
_ClassName = NewType("_ClassName", str)
2121

@@ -27,6 +27,11 @@ def parse_reference_path(ref_path_raw: str) -> Union[_ReferencePath, ParseError]
2727
return cast(_ReferencePath, parsed.fragment)
2828

2929

30+
@attr.s(auto_attribs=True)
31+
class _Holder(Generic[T]):
32+
data: Optional[T]
33+
34+
3035
@attr.s(auto_attribs=True, frozen=True)
3136
class Class:
3237
"""Represents Python class which will be generated from an OpenAPI schema"""
@@ -56,22 +61,33 @@ def from_string(*, string: str, config: Config) -> "Class":
5661
class Schemas:
5762
"""Structure for containing all defined, shareable, and reusable schemas (attr classes and Enums)"""
5863

59-
classes_by_reference: Dict[_ReferencePath, Property] = attr.ib(factory=dict)
60-
classes_by_name: Dict[_ClassName, Property] = attr.ib(factory=dict)
64+
classes_by_reference: Dict[
65+
_ReferencePath, _Holder[Union[EnumProperty, ModelProperty, RecursiveReferenceInterupt]]
66+
] = attr.ib(factory=dict)
67+
classes_by_name: Dict[
68+
_ClassName, _Holder[Union[EnumProperty, ModelProperty, RecursiveReferenceInterupt]]
69+
] = attr.ib(factory=dict)
6170
errors: List[ParseError] = attr.ib(factory=list)
6271

6372

6473
def update_schemas_with(
65-
*, ref_path: _ReferencePath, data: Union[oai.Reference, oai.Schema], schemas: Schemas, config: Config
74+
*,
75+
ref_path: _ReferencePath,
76+
data: Union[oai.Reference, oai.Schema],
77+
schemas: Schemas,
78+
visited: Set[_ReferencePath],
79+
config: Config,
6680
) -> Union[Schemas, PropertyError]:
6781
if isinstance(data, oai.Reference):
68-
return _update_schemas_with_reference(ref_path=ref_path, data=data, schemas=schemas, config=config)
82+
return _update_schemas_with_reference(
83+
ref_path=ref_path, data=data, schemas=schemas, visited=visited, config=config
84+
)
6985
else:
70-
return _update_schemas_with_data(ref_path=ref_path, data=data, schemas=schemas, config=config)
86+
return _update_schemas_with_data(ref_path=ref_path, data=data, schemas=schemas, visited=visited, config=config)
7187

7288

7389
def _update_schemas_with_reference(
74-
*, ref_path: _ReferencePath, data: oai.Reference, schemas: Schemas, config: Config
90+
*, ref_path: _ReferencePath, data: oai.Reference, schemas: Schemas, visited: Set[_ReferencePath], config: Config
7591
) -> Union[Schemas, PropertyError]:
7692
reference_pointer = parse_reference_path(data.ref)
7793
if isinstance(reference_pointer, ParseError):
@@ -85,7 +101,7 @@ def _update_schemas_with_reference(
85101

86102

87103
def _update_schemas_with_data(
88-
*, ref_path: _ReferencePath, data: oai.Schema, schemas: Schemas, config: Config
104+
*, ref_path: _ReferencePath, data: oai.Schema, schemas: Schemas, visited: Set[_ReferencePath], config: Config
89105
) -> Union[Schemas, PropertyError]:
90106
from . import property_from_data
91107

@@ -94,8 +110,19 @@ def _update_schemas_with_data(
94110
data=data, name=ref_path, schemas=schemas, required=True, parent_name="", config=config
95111
)
96112

113+
114+
holder = schemas.classes_by_reference.get(ref_path)
97115
if isinstance(prop, PropertyError):
116+
if ref_path in visited and not holder:
117+
holder = _Holder(data=RecursiveReferenceInterupt())
118+
schemas = attr.evolve(schemas, classes_by_reference={ref_path: holder, **schemas.classes_by_reference})
119+
return RecursiveReferenceInterupt(schemas=schemas)
98120
return prop
99121

100-
schemas = attr.evolve(schemas, classes_by_reference={ref_path: prop, **schemas.classes_by_reference})
122+
if holder:
123+
holder.data = prop
124+
else:
125+
schemas = attr.evolve(
126+
schemas, classes_by_reference={ref_path: _Holder(data=prop), **schemas.classes_by_reference}
127+
)
101128
return schemas

0 commit comments

Comments
 (0)