diff --git a/.changeset/support_empty_dict_default_on_freeform_objects.md b/.changeset/support_empty_dict_default_on_freeform_objects.md new file mode 100644 index 000000000..cbbb63824 --- /dev/null +++ b/.changeset/support_empty_dict_default_on_freeform_objects.md @@ -0,0 +1,9 @@ +--- +default: patch +--- + +# Support `default: {}` on freeform object schemas + +A schema declared as `type: object` with `additionalProperties: true` and no declared properties (often appearing inside an `anyOf` with `null`) could not carry a `default: {}`. The parser rejected the default with `ModelProperty cannot have a default value`, which silently dropped the enclosing schema and every endpoint that referenced it. + +The default `{}` is now accepted on such freeform models and generates an empty-container initializer. The imports of inner models with a non-`None` default are also promoted from lazy `TYPE_CHECKING` imports to runtime imports, so the generated default expression resolves correctly at class-definition time. diff --git a/end_to_end_tests/functional_tests/generated_code_execution/test_freeform_object_defaults.py b/end_to_end_tests/functional_tests/generated_code_execution/test_freeform_object_defaults.py new file mode 100644 index 000000000..4bfb85b2b --- /dev/null +++ b/end_to_end_tests/functional_tests/generated_code_execution/test_freeform_object_defaults.py @@ -0,0 +1,37 @@ +from end_to_end_tests.functional_tests.helpers import ( + with_generated_client_fixture, + with_generated_code_imports, +) + + +@with_generated_client_fixture( +""" +components: + schemas: + ModelWithFreeformDefault: + type: object + properties: + extras: + anyOf: + - type: object + additionalProperties: true + - type: "null" + default: {} +""" +) +@with_generated_code_imports(".models.ModelWithFreeformDefault") +class TestFreeformObjectDefault: + """A freeform object (``type: object`` with ``additionalProperties: true`` and no + declared properties) inside a union with ``default: {}`` should generate a model + whose default initializer constructs the empty inner container. + """ + + def test_default_is_constructed(self, ModelWithFreeformDefault): + instance = ModelWithFreeformDefault() + assert instance.extras.additional_properties == {} + + def test_explicit_value_overrides_default(self, ModelWithFreeformDefault): + inner_type = type(ModelWithFreeformDefault().extras) + custom = inner_type.from_dict({"a": 1}) + instance = ModelWithFreeformDefault(extras=custom) + assert instance.to_dict() == {"extras": {"a": 1}} diff --git a/openapi_python_client/parser/properties/model_property.py b/openapi_python_client/parser/properties/model_property.py index 636b71a34..8c46fdef4 100644 --- a/openapi_python_client/parser/properties/model_property.py +++ b/openapi_python_client/parser/properties/model_property.py @@ -125,10 +125,18 @@ def build( ) return prop, schemas - @classmethod - def convert_value(cls, value: Any) -> Value | None | PropertyError: + def convert_value(self, value: Any) -> Value | None | PropertyError: if value is not None: - return PropertyError(detail="ModelProperty cannot have a default value") # pragma: no cover + is_empty_dict = isinstance(value, dict) and not value + if self.required_properties is not None or self.optional_properties is not None: + has_no_props = not self.required_properties and not self.optional_properties + else: + has_no_props = ( + not self.data.properties and not self.data.allOf and not self.data.anyOf and not self.data.oneOf + ) + if is_empty_dict and has_no_props: + return Value(python_code=f"{self.class_info.name}()", raw_value=value) + return PropertyError(detail="ModelProperty cannot have a default value") return None def __attrs_post_init__(self) -> None: @@ -157,6 +165,8 @@ def get_imports(self, *, prefix: str) -> set[str]: "from typing import cast", } ) + if self.default is not None: + imports.add(f"from {prefix}{self.self_import}") return imports def get_lazy_imports(self, *, prefix: str) -> set[str]: @@ -166,6 +176,8 @@ def get_lazy_imports(self, *, prefix: str) -> set[str]: prefix: A prefix to put before any relative (local) module names. This should be the number of . to get back to the root of the generated client. """ + if self.default is not None: + return set() return {f"from {prefix}{self.self_import}"} def set_relative_imports(self, relative_imports: set[str]) -> None: diff --git a/openapi_python_client/parser/properties/union.py b/openapi_python_client/parser/properties/union.py index 3091c793b..b7a8f25a0 100644 --- a/openapi_python_client/parser/properties/union.py +++ b/openapi_python_client/parser/properties/union.py @@ -196,11 +196,17 @@ def get_imports(self, *, prefix: str) -> set[str]: """ imports = super().get_imports(prefix=prefix) for inner_prop in self.inner_properties: - imports.update(inner_prop.get_imports(prefix=prefix)) + if self.default is not None: + imports.update(inner_prop.get_imports(prefix=prefix)) + imports.update(inner_prop.get_lazy_imports(prefix=prefix)) + else: + imports.update(inner_prop.get_imports(prefix=prefix)) imports.add("from typing import cast") return imports def get_lazy_imports(self, *, prefix: str) -> set[str]: + if self.default is not None: + return set() lazy_imports = super().get_lazy_imports(prefix=prefix) for inner_prop in self.inner_properties: lazy_imports.update(inner_prop.get_lazy_imports(prefix=prefix)) diff --git a/tests/test_parser/test_properties/test_model_property.py b/tests/test_parser/test_properties/test_model_property.py index 8d686edd7..4ef6ec951 100644 --- a/tests/test_parser/test_properties/test_model_property.py +++ b/tests/test_parser/test_properties/test_model_property.py @@ -40,6 +40,15 @@ def test_get_imports(self, model_property_factory): "from typing import cast", } + def test_get_imports_with_default(self, model_property_factory): + prop = model_property_factory(required=False, default="default") + + assert prop.get_imports(prefix="..") == { + "from ..types import UNSET, Unset", + "from typing import cast", + "from ..models.my_module import MyClass", + } + def test_get_lazy_imports(self, model_property_factory): prop = model_property_factory(required=False) @@ -47,10 +56,52 @@ def test_get_lazy_imports(self, model_property_factory): "from ..models.my_module import MyClass", } + def test_get_lazy_imports_with_default(self, model_property_factory): + prop = model_property_factory(required=False, default="default") + + assert prop.get_lazy_imports(prefix="..") == set() + def test_get_base_type_string(self, model_property_factory): m = model_property_factory() assert m.get_base_type_string() == "MyClass" + def test_convert_value(self, model_property_factory): + prop = model_property_factory( + required_properties=["prop1"], + ) + assert isinstance(prop.convert_value({}), PropertyError) + assert prop.convert_value(None) is None + + empty_prop = model_property_factory( + required_properties=[], + optional_properties=[], + ) + assert empty_prop.convert_value(None) is None + val = empty_prop.convert_value({}) + assert val.python_code == "MyClass()" + assert val.raw_value == {} + + def test_convert_value_unprocessed(self, model_property_factory): + # When required_properties is None, it should check self.data (unprocessed) + # 1. Schema with properties + prop_with_data = model_property_factory( + required_properties=None, + optional_properties=None, + data=oai.Schema.model_construct(properties={"prop1": oai.Schema.model_construct()}), + ) + assert isinstance(prop_with_data.convert_value({}), PropertyError) + + # 2. Empty Schema (freeform) + empty_prop_with_data = model_property_factory( + required_properties=None, + optional_properties=None, + data=oai.Schema.model_construct(), + ) + assert empty_prop_with_data.convert_value(None) is None + val = empty_prop_with_data.convert_value({}) + assert val.python_code == "MyClass()" + assert val.raw_value == {} + class TestBuild: @pytest.mark.parametrize(