diff --git a/openapi_python_client/parser/properties/__init__.py b/openapi_python_client/parser/properties/__init__.py index b1701959f..315284184 100644 --- a/openapi_python_client/parser/properties/__init__.py +++ b/openapi_python_client/parser/properties/__init__.py @@ -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 @@ -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 diff --git a/openapi_python_client/parser/properties/model_property.py b/openapi_python_client/parser/properties/model_property.py index 0cfb7a902..9b5410f5e 100644 --- a/openapi_python_client/parser/properties/model_property.py +++ b/openapi_python_client/parser/properties/model_property.py @@ -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, @@ -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]: @@ -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 diff --git a/openapi_python_client/templates/model.py.jinja b/openapi_python_client/templates/model.py.jinja index c4c23c878..306ed4527 100644 --- a/openapi_python_client/templates/model.py.jinja +++ b/openapi_python_client/templates/model.py.jinja @@ -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 %} diff --git a/tests/test_parser/test_properties/test_model_property.py b/tests/test_parser/test_properties/test_model_property.py index 5f179eab2..757f2c99d 100644 --- a/tests/test_parser/test_properties/test_model_property.py +++ b/tests/test_parser/test_properties/test_model_property.py @@ -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