Skip to content

Commit 44c8710

Browse files
fix: skip redundant isinstance dispatch for homogeneous model unions
When a property is a union of schemas that are all object/model types (e.g. `oneOf: [ModelA, ModelB]`), `union_property.py.jinja`'s `transform` macro generated a per-member `isinstance` dispatch in `to_dict()`, even though every branch does the exact same thing (`.to_dict()`). The check never affects the outcome, since every generated model class converts identically. Detect when every union member is model-shaped and skip the per-member dispatch: - required: unconditional `dest = source.to_dict()`, no isinstance at all - optional: `else: dest = source.to_dict()` after the existing Unset guard - optional request bodies (`skip_unset=True`, no Unset guard emitted yet): guard with `if not isinstance(source, Unset)` instead of the previous single-member `isinstance(source, ModelX)` check Mixed unions (model + primitive/enum/None/Any members) are unaffected and keep their existing per-member dispatch, since those branches genuinely differ in behavior. Discussion: #1486
1 parent 4a2f3db commit 44c8710

6 files changed

Lines changed: 107 additions & 11 deletions

File tree

end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/api/default/post_types_unions_duplicate_types.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def _get_kwargs(
2020
"url": "/types/unions/duplicate-types",
2121
}
2222

23-
if isinstance(body, AModel):
23+
if not isinstance(body, Unset):
2424
_kwargs["json"] = body.to_dict()
2525

2626
headers["Content-Type"] = "application/json"

end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/models/a_model.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,6 @@ def to_dict(self) -> dict[str, Any]:
179179
not_required_one_of_models: dict[str, Any] | Unset
180180
if isinstance(self.not_required_one_of_models, Unset):
181181
not_required_one_of_models = UNSET
182-
elif isinstance(self.not_required_one_of_models, FreeFormModel):
183-
not_required_one_of_models = self.not_required_one_of_models.to_dict()
184182
else:
185183
not_required_one_of_models = self.not_required_one_of_models.to_dict()
186184

end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/models/extended.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,6 @@ def to_dict(self) -> dict[str, Any]:
182182
not_required_one_of_models: dict[str, Any] | Unset
183183
if isinstance(self.not_required_one_of_models, Unset):
184184
not_required_one_of_models = UNSET
185-
elif isinstance(self.not_required_one_of_models, FreeFormModel):
186-
not_required_one_of_models = self.not_required_one_of_models.to_dict()
187185
else:
188186
not_required_one_of_models = self.not_required_one_of_models.to_dict()
189187

end_to_end_tests/golden-records/my-test-api-client/my_test_api_client/models/model_with_union_property_inlined.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,9 @@ class ModelWithUnionPropertyInlined:
2525
fruit: ModelWithUnionPropertyInlinedApples | ModelWithUnionPropertyInlinedBananas | Unset = UNSET
2626

2727
def to_dict(self) -> dict[str, Any]:
28-
from ..models.model_with_union_property_inlined_apples import (
29-
ModelWithUnionPropertyInlinedApples, # noqa: PLC0415
30-
)
31-
3228
fruit: dict[str, Any] | Unset
3329
if isinstance(self.fruit, Unset):
3430
fruit = UNSET
35-
elif isinstance(self.fruit, ModelWithUnionPropertyInlinedApples):
36-
fruit = self.fruit.to_dict()
3731
else:
3832
fruit = self.fruit.to_dict()
3933

openapi_python_client/templates/property_templates/union_property.py.jinja

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ def _parse_{{ property.python_name }}(data: object) -> {{ property.get_type_stri
4040
{% endmacro %}
4141

4242
{% macro transform(property, source, destination, declare_type=True, skip_unset=False) %}
43+
{% set inner_templates = property.inner_properties | map(attribute="template") | unique | list %}
44+
{% set homogeneous_model_union = property.inner_properties and inner_templates == ["model_property.py.jinja"] %}
4345
{% set ns = namespace(contains_properties_without_transform = false, contains_modified_properties = not property.required, has_if = false) %}
4446
{% if declare_type %}{{ destination }}: {{ property.get_type_string(json=True) | as_unembedded_code }}{% endif %}
4547

@@ -48,6 +50,17 @@ if isinstance({{ source }}, Unset):
4850
{{ destination }} = UNSET
4951
{% set ns.has_if = true %}
5052
{% endif %}
53+
{% if homogeneous_model_union %}
54+
{% if property.required %}
55+
{{ destination }} = {{ source }}.to_dict()
56+
{% elif ns.has_if %}
57+
else:
58+
{{ destination }} = {{ source }}.to_dict()
59+
{% else %}
60+
if not isinstance({{ source }}, Unset):
61+
{{ destination }} = {{ source }}.to_dict()
62+
{% endif %}
63+
{% else %}
5164
{% for inner_property in property.inner_properties %}
5265
{% import "property_templates/" + inner_property.template as inner_template %}
5366
{% if not inner_template.transform %}
@@ -72,6 +85,7 @@ else:
7285
{%- elif ns.contains_properties_without_transform %}
7386
{{ destination }} = {{ source }}
7487
{%- endif %}
88+
{% endif %}
7589
{% endmacro %}
7690

7791

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Tests for union_property.py.jinja's `transform` macro.
2+
3+
See issue: redundant `isinstance` dispatch when every union member is a model.
4+
"""
5+
6+
import pytest
7+
from jinja2 import Environment, PackageLoader
8+
9+
from openapi_python_client import TEMPLATE_FILTERS
10+
11+
12+
@pytest.fixture
13+
def jinja_env() -> Environment:
14+
"""A jinja2 environment matching the one used for real code generation.
15+
16+
Needs the `loopcontrols` extension (for `{% continue %}`) which the shared `env` fixture in
17+
`tests/test_templates/conftest.py` does not enable.
18+
"""
19+
env = Environment(
20+
loader=PackageLoader("openapi_python_client"),
21+
trim_blocks=True,
22+
lstrip_blocks=True,
23+
extensions=["jinja2.ext.loopcontrols"],
24+
)
25+
env.filters.update(TEMPLATE_FILTERS)
26+
return env
27+
28+
29+
def test_transform_homogeneous_model_union_skips_isinstance_dispatch(
30+
jinja_env: Environment, union_property_factory, model_property_factory
31+
) -> None:
32+
"""When every union member is a model, `.to_dict()` is called unconditionally (no per-member isinstance
33+
dispatch), since every branch's body is identical regardless of which member actually matched.
34+
"""
35+
model_a = model_property_factory(name="model_a", required=True)
36+
model_b = model_property_factory(name="model_b", required=True)
37+
union = union_property_factory(name="both_models", required=False, inner_properties=[model_a, model_b])
38+
39+
template = jinja_env.get_template("property_templates/union_property.py.jinja")
40+
result = template.module.transform(union, "self.both_models", "both_models")
41+
42+
assert "isinstance(self.both_models, Unset)" in result
43+
assert "isinstance(self.both_models, MyClass)" not in result
44+
assert result.count(".to_dict()") == 1
45+
46+
47+
def test_transform_mixed_union_keeps_isinstance_dispatch(
48+
jinja_env: Environment, union_property_factory, model_property_factory, string_property_factory
49+
) -> None:
50+
"""Mixed unions (model + non-model members) must keep the existing per-member isinstance dispatch,
51+
since those branches genuinely differ in behavior.
52+
"""
53+
model_a = model_property_factory(name="model_a", required=True)
54+
string_b = string_property_factory(name="string_b", required=True)
55+
union = union_property_factory(name="mixed", required=False, inner_properties=[model_a, string_b])
56+
57+
template = jinja_env.get_template("property_templates/union_property.py.jinja")
58+
result = template.module.transform(union, "self.mixed", "mixed")
59+
60+
assert "isinstance(self.mixed, MyClass)" in result
61+
assert result.count(".to_dict()") == 1
62+
63+
64+
def test_transform_required_homogeneous_model_union_has_no_guard(
65+
jinja_env: Environment, union_property_factory, model_property_factory
66+
) -> None:
67+
"""A required homogeneous-model union can never be Unset, so `.to_dict()` is called with no guard at all."""
68+
model_a = model_property_factory(name="model_a", required=True)
69+
model_b = model_property_factory(name="model_b", required=True)
70+
union = union_property_factory(name="both_models", required=True, inner_properties=[model_a, model_b])
71+
72+
template = jinja_env.get_template("property_templates/union_property.py.jinja")
73+
result = template.module.transform(union, "self.both_models", "both_models")
74+
75+
assert "isinstance" not in result
76+
assert result.strip() == "both_models: dict[str, Any]\nboth_models = self.both_models.to_dict()"
77+
78+
79+
def test_transform_skip_unset_homogeneous_model_union_still_guards_unset(
80+
jinja_env: Environment, union_property_factory, model_property_factory
81+
) -> None:
82+
"""Request bodies render `transform` with `skip_unset=True` (the `UNSET` default is assigned elsewhere), but an
83+
optional homogeneous-model union can still *be* `Unset` at runtime, so `.to_dict()` must still be guarded --
84+
unconditionally calling it here would raise `AttributeError`/fail type-checking (`Unset` has no `to_dict`).
85+
"""
86+
model_a = model_property_factory(name="model_a", required=True)
87+
union = union_property_factory(name="body", required=False, inner_properties=[model_a])
88+
89+
template = jinja_env.get_template("property_templates/union_property.py.jinja")
90+
result = template.module.transform(union, "body", '_kwargs["json"]', skip_unset=True, declare_type=False)
91+
92+
assert result.strip() == 'if not isinstance(body, Unset):\n _kwargs["json"] = body.to_dict()'

0 commit comments

Comments
 (0)