Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/support_empty_dict_default_on_freeform_objects.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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}}
18 changes: 15 additions & 3 deletions openapi_python_client/parser/properties/model_property.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]:
Expand All @@ -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:
Expand Down
8 changes: 7 additions & 1 deletion openapi_python_client/parser/properties/union.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
51 changes: 51 additions & 0 deletions tests/test_parser/test_properties/test_model_property.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,68 @@ 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)

assert prop.get_lazy_imports(prefix="..") == {
"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(
Expand Down