From a7a9c86f38ba32e8406cf6cbb323ca732155edf8 Mon Sep 17 00:00:00 2001 From: Alexander Wenzel Date: Sat, 19 Sep 2026 06:50:30 +0200 Subject: [PATCH] fix: ignore an empty object default instead of dropping the schema A property whose type is a model schema could not carry `default: {}`. `ModelProperty.convert_value` rejected it with `ModelProperty cannot have a default value`, or with `Value {} is not valid, only None is allowed` when the model sat in a union. That error propagated as a warning, removed the enclosing schema and every endpoint that referenced it, and still exited 0. An empty object carries no information for a model default, so it is now ignored and the property generates with no default. This is how an inline `default: {}` on a property and a bare `$ref` with a sibling `default` already behave. A non-empty default is still rejected. Tests: a functional test in `end_to_end_tests/functional_tests` covers the empty object default on a union member and on a model referenced through an `allOf`. Unit tests cover `ModelProperty.convert_value`. --- .../ignore_empty_object_default_on_models.md | 9 +++ .../test_empty_object_defaults.py | 55 +++++++++++++++++++ .../parser/properties/model_property.py | 5 +- .../test_properties/test_model_property.py | 9 +++ 4 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 .changeset/ignore_empty_object_default_on_models.md create mode 100644 end_to_end_tests/functional_tests/generated_code_execution/test_empty_object_defaults.py diff --git a/.changeset/ignore_empty_object_default_on_models.md b/.changeset/ignore_empty_object_default_on_models.md new file mode 100644 index 000000000..bfb2012f1 --- /dev/null +++ b/.changeset/ignore_empty_object_default_on_models.md @@ -0,0 +1,9 @@ +--- +default: patch +--- + +# Do not drop a schema over an empty object default + +A property whose type is a model schema could not carry `default: {}`. The parser rejected it with `ModelProperty cannot have a default value`, or with `Value {} is not valid, only None is allowed` when the model sat in a union. The generator warned, dropped the enclosing schema and every endpoint that referenced it, and still exited 0. + +An empty object carries no information for a model default, so it is now ignored and the property generates with no default. This is how an inline `default: {}` on a property and a bare `$ref` with a sibling `default` already behave. A non-empty default is still rejected. diff --git a/end_to_end_tests/functional_tests/generated_code_execution/test_empty_object_defaults.py b/end_to_end_tests/functional_tests/generated_code_execution/test_empty_object_defaults.py new file mode 100644 index 000000000..0caf54082 --- /dev/null +++ b/end_to_end_tests/functional_tests/generated_code_execution/test_empty_object_defaults.py @@ -0,0 +1,55 @@ +from end_to_end_tests.functional_tests.helpers import ( + with_generated_client_fixture, + with_generated_code_imports, +) + + +@with_generated_client_fixture( + """ +components: + schemas: + Holder: + type: object + properties: + extras: + anyOf: + - type: object + additionalProperties: true + - type: "null" + default: {} +""" +) +@with_generated_code_imports(".models.Holder", ".types.UNSET") +class TestEmptyObjectDefaultOnAUnionMember: + """An empty object default on a union member leaves the enclosing schema in place. The property generates + with no default.""" + + def test_model_generates_without_the_default(self, Holder, UNSET): + assert Holder().extras is UNSET + + def test_explicit_value_is_kept(self, Holder): + assert Holder.from_dict({"extras": {"a": 1}}).to_dict() == {"extras": {"a": 1}} + + +@with_generated_client_fixture( + """ +components: + schemas: + Free: + type: object + additionalProperties: true + Holder: + type: object + properties: + extras: + allOf: + - $ref: "#/components/schemas/Free" + default: {} +""" +) +@with_generated_code_imports(".models.Holder", ".types.UNSET") +class TestEmptyObjectDefaultOnAReferencedModel: + """The same holds when the model carrying the default is referenced through an ``allOf``.""" + + def test_model_generates_without_the_default(self, Holder, UNSET): + assert Holder().extras is UNSET diff --git a/openapi_python_client/parser/properties/model_property.py b/openapi_python_client/parser/properties/model_property.py index 7e3b545e0..ed8a00ccb 100644 --- a/openapi_python_client/parser/properties/model_property.py +++ b/openapi_python_client/parser/properties/model_property.py @@ -128,8 +128,11 @@ def build( @classmethod def convert_value(cls, value: Any) -> Value | PropertyError | None: + if isinstance(value, dict) and not value: + # An empty object adds nothing to the default, and rejecting it drops the whole schema. + return None if value is not None: - return PropertyError(detail="ModelProperty cannot have a default value") # pragma: no cover + return PropertyError(detail="ModelProperty cannot have a default value") return None def __attrs_post_init__(self) -> None: diff --git a/tests/test_parser/test_properties/test_model_property.py b/tests/test_parser/test_properties/test_model_property.py index b8dc09213..75c047e34 100644 --- a/tests/test_parser/test_properties/test_model_property.py +++ b/tests/test_parser/test_properties/test_model_property.py @@ -54,6 +54,15 @@ def test_get_base_type_string(self, model_property_factory): m = model_property_factory() assert m.get_base_type_string() == PythonCode("MyClass") + def test_convert_value_passes_none_through(self, model_property_factory): + assert model_property_factory().convert_value(None) is None + + def test_convert_value_ignores_an_empty_object_default(self, model_property_factory): + assert model_property_factory().convert_value({}) is None + + def test_convert_value_rejects_a_non_empty_default(self, model_property_factory): + assert isinstance(model_property_factory().convert_value({"a": 1}), PropertyError) + class TestBuild: @pytest.mark.parametrize(