Skip to content

Commit de81a94

Browse files
fix: 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: {}`. `ModelProperty.convert_value` rejected the default with `ModelProperty cannot have a default value`, which propagated up as a warning and silently dropped the enclosing schema and every endpoint referencing it. `ModelProperty.convert_value` now accepts the empty dict on a freeform model and emits a `ClassName()` constructor call. When `required_properties` has not yet been populated, the check falls back to the raw schema (`data.properties`, `data.allOf`, `data.anyOf`, `data.oneOf`) so referenced schemas not yet processed still get the correct decision. A default value also forces the generated client to import the inner model at runtime rather than only under `TYPE_CHECKING`, otherwise the default initializer would `NameError` at class-definition time. `get_imports` and `get_lazy_imports` are updated accordingly on both `ModelProperty` and `UnionProperty` so inner-property imports are promoted when the property has a non-`None` default. Tests: a functional test in `end_to_end_tests/functional_tests` covers the inline spec, and unit tests in `tests/test_parser/test_properties/test_model_property.py` cover the new branches in `convert_value`, `get_imports`, and `get_lazy_imports`.
1 parent 7939364 commit de81a94

5 files changed

Lines changed: 119 additions & 4 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
default: patch
3+
---
4+
5+
# Support `default: {}` on freeform object schemas
6+
7+
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.
8+
9+
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.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from end_to_end_tests.functional_tests.helpers import (
2+
with_generated_client_fixture,
3+
with_generated_code_imports,
4+
)
5+
6+
7+
@with_generated_client_fixture(
8+
"""
9+
components:
10+
schemas:
11+
ModelWithFreeformDefault:
12+
type: object
13+
properties:
14+
extras:
15+
anyOf:
16+
- type: object
17+
additionalProperties: true
18+
- type: "null"
19+
default: {}
20+
"""
21+
)
22+
@with_generated_code_imports(".models.ModelWithFreeformDefault")
23+
class TestFreeformObjectDefault:
24+
"""A freeform object (``type: object`` with ``additionalProperties: true`` and no
25+
declared properties) inside a union with ``default: {}`` should generate a model
26+
whose default initializer constructs the empty inner container.
27+
"""
28+
29+
def test_default_is_constructed(self, ModelWithFreeformDefault):
30+
instance = ModelWithFreeformDefault()
31+
assert instance.extras.additional_properties == {}
32+
33+
def test_explicit_value_overrides_default(self, ModelWithFreeformDefault):
34+
inner_type = type(ModelWithFreeformDefault().extras)
35+
custom = inner_type.from_dict({"a": 1})
36+
instance = ModelWithFreeformDefault(extras=custom)
37+
assert instance.to_dict() == {"extras": {"a": 1}}

openapi_python_client/parser/properties/model_property.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,18 @@ def build(
125125
)
126126
return prop, schemas
127127

128-
@classmethod
129-
def convert_value(cls, value: Any) -> Value | None | PropertyError:
128+
def convert_value(self, value: Any) -> Value | None | PropertyError:
130129
if value is not None:
131-
return PropertyError(detail="ModelProperty cannot have a default value") # pragma: no cover
130+
is_empty_dict = isinstance(value, dict) and not value
131+
if self.required_properties is not None or self.optional_properties is not None:
132+
has_no_props = not self.required_properties and not self.optional_properties
133+
else:
134+
has_no_props = (
135+
not self.data.properties and not self.data.allOf and not self.data.anyOf and not self.data.oneOf
136+
)
137+
if is_empty_dict and has_no_props:
138+
return Value(python_code=f"{self.class_info.name}()", raw_value=value)
139+
return PropertyError(detail="ModelProperty cannot have a default value")
132140
return None
133141

134142
def __attrs_post_init__(self) -> None:
@@ -157,6 +165,8 @@ def get_imports(self, *, prefix: str) -> set[str]:
157165
"from typing import cast",
158166
}
159167
)
168+
if self.default is not None:
169+
imports.add(f"from {prefix}{self.self_import}")
160170
return imports
161171

162172
def get_lazy_imports(self, *, prefix: str) -> set[str]:
@@ -166,6 +176,8 @@ def get_lazy_imports(self, *, prefix: str) -> set[str]:
166176
prefix: A prefix to put before any relative (local) module names. This should be the number of . to get
167177
back to the root of the generated client.
168178
"""
179+
if self.default is not None:
180+
return set()
169181
return {f"from {prefix}{self.self_import}"}
170182

171183
def set_relative_imports(self, relative_imports: set[str]) -> None:

openapi_python_client/parser/properties/union.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,11 +196,17 @@ def get_imports(self, *, prefix: str) -> set[str]:
196196
"""
197197
imports = super().get_imports(prefix=prefix)
198198
for inner_prop in self.inner_properties:
199-
imports.update(inner_prop.get_imports(prefix=prefix))
199+
if self.default is not None:
200+
imports.update(inner_prop.get_imports(prefix=prefix))
201+
imports.update(inner_prop.get_lazy_imports(prefix=prefix))
202+
else:
203+
imports.update(inner_prop.get_imports(prefix=prefix))
200204
imports.add("from typing import cast")
201205
return imports
202206

203207
def get_lazy_imports(self, *, prefix: str) -> set[str]:
208+
if self.default is not None:
209+
return set()
204210
lazy_imports = super().get_lazy_imports(prefix=prefix)
205211
for inner_prop in self.inner_properties:
206212
lazy_imports.update(inner_prop.get_lazy_imports(prefix=prefix))

tests/test_parser/test_properties/test_model_property.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,68 @@ def test_get_imports(self, model_property_factory):
4040
"from typing import cast",
4141
}
4242

43+
def test_get_imports_with_default(self, model_property_factory):
44+
prop = model_property_factory(required=False, default="default")
45+
46+
assert prop.get_imports(prefix="..") == {
47+
"from ..types import UNSET, Unset",
48+
"from typing import cast",
49+
"from ..models.my_module import MyClass",
50+
}
51+
4352
def test_get_lazy_imports(self, model_property_factory):
4453
prop = model_property_factory(required=False)
4554

4655
assert prop.get_lazy_imports(prefix="..") == {
4756
"from ..models.my_module import MyClass",
4857
}
4958

59+
def test_get_lazy_imports_with_default(self, model_property_factory):
60+
prop = model_property_factory(required=False, default="default")
61+
62+
assert prop.get_lazy_imports(prefix="..") == set()
63+
5064
def test_get_base_type_string(self, model_property_factory):
5165
m = model_property_factory()
5266
assert m.get_base_type_string() == "MyClass"
5367

68+
def test_convert_value(self, model_property_factory):
69+
prop = model_property_factory(
70+
required_properties=["prop1"],
71+
)
72+
assert isinstance(prop.convert_value({}), PropertyError)
73+
assert prop.convert_value(None) is None
74+
75+
empty_prop = model_property_factory(
76+
required_properties=[],
77+
optional_properties=[],
78+
)
79+
assert empty_prop.convert_value(None) is None
80+
val = empty_prop.convert_value({})
81+
assert val.python_code == "MyClass()"
82+
assert val.raw_value == {}
83+
84+
def test_convert_value_unprocessed(self, model_property_factory):
85+
# When required_properties is None, it should check self.data (unprocessed)
86+
# 1. Schema with properties
87+
prop_with_data = model_property_factory(
88+
required_properties=None,
89+
optional_properties=None,
90+
data=oai.Schema.model_construct(properties={"prop1": oai.Schema.model_construct()}),
91+
)
92+
assert isinstance(prop_with_data.convert_value({}), PropertyError)
93+
94+
# 2. Empty Schema (freeform)
95+
empty_prop_with_data = model_property_factory(
96+
required_properties=None,
97+
optional_properties=None,
98+
data=oai.Schema.model_construct(),
99+
)
100+
assert empty_prop_with_data.convert_value(None) is None
101+
val = empty_prop_with_data.convert_value({})
102+
assert val.python_code == "MyClass()"
103+
assert val.raw_value == {}
104+
54105

55106
class TestBuild:
56107
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)