Skip to content

Commit 9ddfe10

Browse files
committed
fix(python): honour include/exclude filters in model dumps
1 parent dce293e commit 9ddfe10

55 files changed

Lines changed: 543 additions & 116 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
id: 1792775030-0ae07c4d
2+
features:
3+
- core
4+
targets:
5+
- pythonv2
6+
type: fix
7+
bump: patch
8+
description: leave fields filtered by include, exclude, exclude_unset or exclude_defaults out of model dumps
9+
author: 2ynn
10+
date: "2026-10-23"

‎templates/perms.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1688,6 +1688,7 @@ var fileMode = map[string]fs.FileMode{
16881688
"templates/pythonv2/tests/primary/test_helpers.py": 0o644,
16891689
"templates/pythonv2/tests/primary/test_hooks_additional.py.stmpl": 0o644,
16901690
"templates/pythonv2/tests/primary/test_jsonl_streaming_additional.py": 0o644,
1691+
"templates/pythonv2/tests/primary/test_model_dump_filters_additional.py": 0o644,
16911692
"templates/pythonv2/tests/primary/test_model_serializer_alias.py": 0o644,
16921693
"templates/pythonv2/tests/primary/test_open_discriminated_union.py": 0o644,
16931694
"templates/pythonv2/tests/primary/test_open_enum_union_discrimination.py": 0o644,

‎templates/templates/pythonv2/model.py.stmpl‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ class {{$className}}(BaseModel):
7676
{{- addImport "pydantic" "model_serializer"}}
7777
{{- addImport "types" "UNSET_SENTINEL" true}}
7878
@model_serializer(mode="wrap")
79-
def {{sanitizeSerializeMethodName $type}}(self, handler):
79+
def {{sanitizeSerializeMethodName $type}}(self, handler, info):
8080
{{- $nullDefaultFields := templateNullDefaultFieldList $type}}
8181
{{- $hasNullDefaultField := gt (len $nullDefaultFields) 0}}
8282
{{- if $hasOptionalField}}
@@ -93,6 +93,8 @@ class {{$className}}(BaseModel):
9393

9494
for n, f in type(self).model_fields.items():
9595
k = f.alias or n
96+
if k not in serialized and n not in serialized and not info.exclude_none:
97+
continue
9698
val = serialized.get(k, serialized.get(n))
9799
{{- if $hasAdditionalProperties}}
98100
serialized.pop(k, serialized.pop(n, None))
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import json
2+
from typing import List, Optional
3+
4+
from pydantic import TypeAdapter
5+
6+
from openapi import utils
7+
from openapi.models import errors, shared
8+
from openapi.types import Nullable
9+
10+
11+
def filtered_fields(display_name="name", **kwargs):
12+
return shared.FilteredFields(
13+
display_name=display_name, required_nullable=None, **kwargs
14+
)
15+
16+
17+
class TestModelDumpFilters:
18+
def test_exclude_leaves_required_field_out(self):
19+
model = filtered_fields()
20+
21+
assert "displayName" not in model.model_dump(exclude={"display_name"})
22+
assert "displayName" not in model.model_dump(
23+
by_alias=True, exclude={"display_name"}
24+
)
25+
assert "displayName" not in json.loads(
26+
model.model_dump_json(exclude={"display_name"})
27+
)
28+
29+
def test_include_leaves_other_fields_out(self):
30+
model = filtered_fields(optional_nullable="x")
31+
32+
assert model.model_dump(include={"optional_nullable"}) == {
33+
"optionalNullable": "x"
34+
}
35+
assert model.model_dump(by_alias=True, include={"display_name"}) == {
36+
"displayName": "name"
37+
}
38+
39+
def test_exclude_unset_leaves_unset_fields_out(self):
40+
model = filtered_fields()
41+
42+
assert model.model_dump(exclude_unset=True) == {
43+
"displayName": "name",
44+
"requiredNullable": None,
45+
}
46+
47+
def test_exclude_defaults_leaves_default_fields_out(self):
48+
model = filtered_fields(with_default="other")
49+
50+
assert model.model_dump(exclude_defaults=True) == {
51+
"displayName": "name",
52+
"requiredNullable": None,
53+
"withDefault": "other",
54+
}
55+
56+
def test_exclude_none_still_writes_back_excluded_required_field(self):
57+
model = filtered_fields()
58+
59+
dumped = model.model_dump(exclude={"display_name"}, exclude_none=True)
60+
61+
assert dumped["displayName"] is None
62+
63+
def test_nested_exclude(self):
64+
model = filtered_fields(
65+
child=filtered_fields("child"), children=[filtered_fields("item")]
66+
)
67+
68+
dumped = model.model_dump(
69+
exclude={
70+
"child": {"display_name"},
71+
"children": {"__all__": {"display_name"}},
72+
}
73+
)
74+
75+
assert dumped["displayName"] == "name"
76+
assert "displayName" not in dumped["child"]
77+
assert "displayName" not in dumped["children"][0]
78+
79+
80+
class TestNestedDefaultDumps:
81+
def test_nested_models_keep_api_names(self):
82+
model = filtered_fields(
83+
child=filtered_fields("child"), children=[filtered_fields("item")]
84+
)
85+
86+
for dumped in (model.model_dump(), json.loads(model.model_dump_json())):
87+
assert dumped["child"]["displayName"] == "child"
88+
assert dumped["child"]["requiredNullable"] is None
89+
assert dumped["children"][0]["displayName"] == "item"
90+
91+
def test_model_lists_keep_api_names(self):
92+
adapter = TypeAdapter(List[shared.FilteredFields])
93+
94+
dumped = adapter.dump_python([filtered_fields("first")])
95+
96+
assert dumped[0]["displayName"] == "first"
97+
assert dumped[0]["requiredNullable"] is None
98+
99+
100+
class TestRequestBodies:
101+
def test_explicit_nulls(self):
102+
model = filtered_fields(optional_nullable=None)
103+
104+
assert (
105+
utils.marshal_json(model, shared.FilteredFields)
106+
== '{"displayName":"name","requiredNullable":null,"kind":"filtered","nullDefault":null,"optionalNullable":null,"withDefault":"fallback"}'
107+
)
108+
109+
def test_required_nullable_null(self):
110+
model = shared.PaginationCursorNullableLimitNextCursor(
111+
next_cursor=None, results=["a"]
112+
)
113+
114+
assert (
115+
utils.marshal_json(model, shared.PaginationCursorNullableLimitNextCursor)
116+
== '{"next_cursor":null,"results":["a"]}'
117+
)
118+
119+
def test_nested_nulls_and_api_names(self):
120+
model = filtered_fields(
121+
child=filtered_fields("child", optional_nullable=None),
122+
children=[filtered_fields("item")],
123+
)
124+
125+
assert json.loads(utils.marshal_json(model, shared.FilteredFields)) == {
126+
"displayName": "name",
127+
"requiredNullable": None,
128+
"child": {
129+
"displayName": "child",
130+
"requiredNullable": None,
131+
"kind": "filtered",
132+
"nullDefault": None,
133+
"optionalNullable": None,
134+
"withDefault": "fallback",
135+
},
136+
"children": [
137+
{
138+
"displayName": "item",
139+
"requiredNullable": None,
140+
"kind": "filtered",
141+
"nullDefault": None,
142+
"withDefault": "fallback",
143+
}
144+
],
145+
"kind": "filtered",
146+
"nullDefault": None,
147+
"withDefault": "fallback",
148+
}
149+
150+
def test_null_additional_property_omitted(self):
151+
model = shared.ObjWithAnyAdditionalProperties.model_validate(
152+
{"normalField": "x", "future": None}
153+
)
154+
155+
assert (
156+
utils.marshal_json(model, shared.ObjWithAnyAdditionalProperties)
157+
== '{"normalField":"x"}'
158+
)
159+
160+
def test_additional_property_under_python_name(self):
161+
model = shared.ObjWithAnyAdditionalProperties.model_validate(
162+
{"normalField": None, "normal_field": "extra"}
163+
)
164+
other = shared.FilteredFieldsWithAdditionalProperties.model_validate(
165+
{"displayName": None, "display_name": "extra"}
166+
)
167+
168+
assert (
169+
utils.marshal_json(model, shared.ObjWithAnyAdditionalProperties)
170+
== '{"normalField":"extra"}'
171+
)
172+
assert (
173+
utils.marshal_json(other, shared.FilteredFieldsWithAdditionalProperties)
174+
== '{"displayName":"extra"}'
175+
)
176+
177+
def test_top_level_none(self):
178+
assert utils.marshal_json(None, Optional[shared.FilteredFields]) == ""
179+
assert utils.marshal_json(None, Nullable[shared.FilteredFields]) == "null"
180+
181+
182+
class TestErrorData:
183+
def test_plain_filtering(self):
184+
data = errors.FilteredFieldsErrorData()
185+
186+
assert data.model_dump() == {"detail": None}
187+
assert data.model_dump(exclude_none=True) == {}
188+
assert data.model_dump(exclude_unset=True) == {}
189+
190+
def test_nested_model_honours_exclude(self):
191+
data = errors.FilteredFieldsErrorData(detail=filtered_fields())
192+
193+
dumped = data.model_dump(exclude={"detail": {"display_name"}})
194+
195+
assert "displayName" not in dumped["detail"]
196+
assert dumped["detail"]["requiredNullable"] is None
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
openapi: 3.1.0
2+
security:
3+
- apiKeyAuth: []
4+
- oauth2: []
5+
- clientCredentials: [read, write]
6+
- customSchemeAppId: []
7+
- basicHttp: []
8+
- accessToken: []
9+
- {}
10+
paths:
11+
/errors/model-dump-filters:
12+
get:
13+
operationId: getFilteredFieldsError
14+
description: >-
15+
Declares an error response whose body nests an object schema with
16+
required, nullable, defaulted and const properties, so that serializing
17+
the error body with fields filtered out can be checked against the same
18+
rules as ordinary models.
19+
tags:
20+
- errors
21+
responses:
22+
"200":
23+
description: OK
24+
"400":
25+
description: Error whose body nests FilteredFields
26+
content:
27+
application/json:
28+
schema:
29+
$ref: "#/components/schemas/FilteredFieldsError"
30+
components:
31+
schemas:
32+
FilteredFields:
33+
x-speakeasy-include: true
34+
type: object
35+
description: >-
36+
Object with a required renamed property, required and optional nullable
37+
properties, a property with a default, a nullable property with a null
38+
default, a const property, and nested copies of itself as a property and
39+
as array items. Fields that a serializer is asked to leave out (by
40+
explicit selection, because they were never set, or because they hold
41+
their default) must stay out of the output, while request bodies keep
42+
sending required and explicitly set nullable properties as null.
43+
required:
44+
- displayName
45+
- requiredNullable
46+
- kind
47+
properties:
48+
displayName:
49+
type: string
50+
requiredNullable:
51+
type: [string, "null"]
52+
optionalNullable:
53+
type: [string, "null"]
54+
withDefault:
55+
type: string
56+
default: fallback
57+
nullDefault:
58+
type: [string, "null"]
59+
default: null
60+
kind:
61+
type: string
62+
const: filtered
63+
child:
64+
$ref: "#/components/schemas/FilteredFields"
65+
children:
66+
type: array
67+
items:
68+
$ref: "#/components/schemas/FilteredFields"
69+
FilteredFieldsWithAdditionalProperties:
70+
x-speakeasy-include: true
71+
type: object
72+
description: >-
73+
Object with an optional nullable renamed property and untyped
74+
additional properties, for request bodies that hold both the renamed
75+
property and an additional property keyed by the property's other
76+
spelling.
77+
properties:
78+
displayName:
79+
type: [string, "null"]
80+
additionalProperties: true
81+
FilteredFieldsError:
82+
type: object
83+
description: Error body that nests FilteredFields.
84+
properties:
85+
detail:
86+
$ref: "#/components/schemas/FilteredFields"

0 commit comments

Comments
 (0)