Skip to content

feat: support for recursive and circular references #467

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 47 additions & 27 deletions openapi_python_client/parser/properties/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
"property_from_data",
]

from copy import deepcopy
from itertools import chain
from typing import Any, ClassVar, Dict, Generic, Iterable, Iterator, List, Optional, Set, Tuple, TypeVar, Union
from typing import Any, ClassVar, Dict, Generic, Iterator, List, Optional, Set, Tuple, TypeVar, Union

import attr

Expand Down Expand Up @@ -636,32 +637,51 @@ def build_schemas(
*, components: Dict[str, Union[oai.Reference, oai.Schema]], schemas: Schemas, config: Config
) -> Schemas:
"""Get a list of Schemas from an OpenAPI dict"""
to_process: Iterable[Tuple[str, Union[oai.Reference, oai.Schema]]] = components.items()
still_making_progress = True
errors: List[PropertyError] = []

# References could have forward References so keep going as long as we are making progress
while still_making_progress:
still_making_progress = False
errors = []
next_round = []
# Only accumulate errors from the last round, since we might fix some along the way
for name, data in to_process:
if isinstance(data, oai.Reference):
schemas.errors.append(PropertyError(data=data, detail="Reference schemas are not supported."))
continue
ref_path = parse_reference_path(f"#/components/schemas/{name}")
if isinstance(ref_path, ParseError):
schemas.errors.append(PropertyError(detail=ref_path.detail, data=data))
continue
schemas_or_err = update_schemas_with_data(ref_path=ref_path, data=data, schemas=schemas, config=config)
if isinstance(schemas_or_err, PropertyError):
next_round.append((name, data))
errors.append(schemas_or_err)
continue
schemas = schemas_or_err
still_making_progress = True
to_process = next_round

schemas.errors.extend(errors)
# Create classes witch can be referenced
schemas = build_schemas_without_properties(components=components, schemas=schemas, config=config)

for name, data in components.items():
if isinstance(data, oai.Reference):
schemas.errors.append(PropertyError(data=data, detail="Reference schemas are not supported."))
continue
ref_path = parse_reference_path(f"#/components/schemas/{name}")
if isinstance(ref_path, ParseError):
schemas.errors.append(PropertyError(detail=ref_path.detail, data=data))
continue

schemas_or_err = update_schemas_with_data(ref_path=ref_path, data=data, schemas=schemas, config=config)
if isinstance(schemas_or_err, PropertyError):
errors.append(schemas_or_err)
continue
schemas = schemas_or_err

return schemas


def build_schemas_without_properties(
components: Dict[str, Union[oai.Reference, oai.Schema]],
schemas: Schemas,
config: Config,
) -> Schemas:
"""Adds classes with empty properties. This allows to reference these classes.
Properties will be filled in later.
"""
for name, schema in components.items():
data = deepcopy(schema)
if isinstance(data, oai.Reference):
# Unsupported now. Adds to errors in the top loop.
continue
ref_path = parse_reference_path(f"#/components/schemas/{name}")
if isinstance(ref_path, ParseError):
continue

# Add model for reference without properties. Properties will be added later.
data.properties = None
schemas_or_err = update_schemas_with_data(ref_path=ref_path, data=data, schemas=schemas, config=config)
if isinstance(schemas_or_err, ParseError):
schemas.errors.append(schemas_or_err)
continue
schemas = schemas_or_err
return schemas
77 changes: 48 additions & 29 deletions openapi_python_client/parser/properties/model_property.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,11 @@ def _add_if_no_conflict(new_prop: Property) -> Optional[PropertyError]:
optional_properties.append(prop)
relative_imports.update(prop.get_imports(prefix=".."))

# Except self import
relative_imports = {
relative_import for relative_import in relative_imports if not relative_import.endswith("import " + class_name)
}

return _PropertyData(
optional_props=optional_properties,
required_props=required_properties,
Expand Down Expand Up @@ -211,6 +216,15 @@ def _get_additional_properties(
return additional_properties, schemas


def _get_empty_properties(schemas: Schemas) -> _PropertyData:
return _PropertyData(
optional_props=[],
required_props=[],
relative_imports=set(),
schemas=schemas,
)


def build_model_property(
*, data: oai.Schema, name: str, schemas: Schemas, required: bool, parent_name: Optional[str], config: Config
) -> Tuple[Union[ModelProperty, PropertyError], Schemas]:
Expand All @@ -231,35 +245,40 @@ def build_model_property(
class_string = f"{utils.pascal_case(parent_name)}{utils.pascal_case(class_string)}"
class_info = Class.from_string(string=class_string, config=config)

property_data = _process_properties(data=data, schemas=schemas, class_name=class_info.name, config=config)
if isinstance(property_data, PropertyError):
return property_data, schemas
schemas = property_data.schemas

additional_properties, schemas = _get_additional_properties(
schema_additional=data.additionalProperties, schemas=schemas, class_name=class_info.name, config=config
)
if isinstance(additional_properties, Property):
property_data.relative_imports.update(additional_properties.get_imports(prefix=".."))
elif isinstance(additional_properties, PropertyError):
return additional_properties, schemas

prop = ModelProperty(
class_info=class_info,
required_properties=property_data.required_props,
optional_properties=property_data.optional_props,
relative_imports=property_data.relative_imports,
description=data.description or "",
default=None,
nullable=data.nullable,
required=required,
name=name,
additional_properties=additional_properties,
python_name=utils.PythonIdentifier(value=name, prefix=config.field_prefix),
)
if class_info.name in schemas.classes_by_name:
error = PropertyError(data=data, detail=f'Attempted to generate duplicate models with name "{class_info.name}"')
return error, schemas
existing = schemas.classes_by_name.get(class_info.name)
if isinstance(existing, ModelProperty):
property_data = _process_properties(data=data, schemas=schemas, class_name=class_info.name, config=config)
if isinstance(property_data, PropertyError):
return property_data, schemas
schemas = property_data.schemas
prop = attr.evolve(
existing,
required_properties=property_data.required_props,
optional_properties=property_data.optional_props,
)
else:
property_data = _get_empty_properties(schemas=schemas)
additional_properties, schemas = _get_additional_properties(
schema_additional=data.additionalProperties, schemas=schemas, class_name=class_info.name, config=config
)
if isinstance(additional_properties, Property):
property_data.relative_imports.update(additional_properties.get_imports(prefix=".."))
elif isinstance(additional_properties, PropertyError):
return additional_properties, schemas

prop = ModelProperty(
class_info=class_info,
required_properties=property_data.required_props,
optional_properties=property_data.optional_props,
relative_imports=property_data.relative_imports,
description=data.description or "",
default=None,
nullable=data.nullable,
required=required,
name=name,
additional_properties=additional_properties,
python_name=utils.PythonIdentifier(value=name, prefix=config.field_prefix),
)

schemas = attr.evolve(schemas, classes_by_name={**schemas.classes_by_name, class_info.name: prop})
return prop, schemas
4 changes: 2 additions & 2 deletions openapi_python_client/templates/model.py.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ class {{ class_name }}:
""" {{ model.description }} """
{% for property in model.required_properties + model.optional_properties %}
{% if property.default is none and property.required %}
{{ property.to_string() }}
{{ property.to_string().replace(" " + class_name + "]", " T]") }}
{% endif %}
{% endfor %}
{% for property in model.required_properties + model.optional_properties %}
{% if property.default is not none or not property.required %}
{{ property.to_string() }}
{{ property.to_string().replace(" " + class_name + "]", " T]") }}
{% endif %}
{% endfor %}
{% if model.additional_properties %}
Expand Down
13 changes: 0 additions & 13 deletions tests/test_parser/test_properties/test_model_property.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,19 +127,6 @@ def test_happy_path(self, model_property_factory, string_property_factory, date_
additional_properties=True,
)

def test_model_name_conflict(self):
from openapi_python_client.parser.properties import Schemas, build_model_property

data = oai.Schema.construct()
schemas = Schemas(classes_by_name={"OtherModel": None})

err, new_schemas = build_model_property(
data=data, name="OtherModel", schemas=schemas, required=True, parent_name=None, config=Config()
)

assert new_schemas == schemas
assert err == PropertyError(detail='Attempted to generate duplicate models with name "OtherModel"', data=data)

def test_bad_props_return_error(self):
from openapi_python_client.parser.properties import Schemas, build_model_property

Expand Down