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
10 changes: 10 additions & 0 deletions .changesets/1792775030-0ae07c4d.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
id: 1792775030-0ae07c4d
features:
- core
targets:
- pythonv2
type: fix
bump: patch
description: leave fields filtered by include, exclude, exclude_unset or exclude_defaults out of model dumps
author: 2ynn
date: "2026-10-23"
10 changes: 10 additions & 0 deletions .changesets/1792778028-be2ce88f.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
id: 1792778028-be2ce88f
features:
- core
targets:
- pythonv2
type: fix
bump: patch
description: keep the value of fields with an empty API name in model dumps
author: 2ynn
date: "2026-10-23"
1 change: 1 addition & 0 deletions templates/perms.go
Original file line number Diff line number Diff line change
Expand Up @@ -1688,6 +1688,7 @@ var fileMode = map[string]fs.FileMode{
"templates/pythonv2/tests/primary/test_helpers.py": 0o644,
"templates/pythonv2/tests/primary/test_hooks_additional.py.stmpl": 0o644,
"templates/pythonv2/tests/primary/test_jsonl_streaming_additional.py": 0o644,
"templates/pythonv2/tests/primary/test_model_dump_filters_additional.py": 0o644,
"templates/pythonv2/tests/primary/test_model_serializer_alias.py": 0o644,
"templates/pythonv2/tests/primary/test_open_discriminated_union.py": 0o644,
"templates/pythonv2/tests/primary/test_open_enum_union_discrimination.py": 0o644,
Expand Down
6 changes: 4 additions & 2 deletions templates/templates/pythonv2/model.py.stmpl
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ class {{$className}}(BaseModel):
{{- addImport "pydantic" "model_serializer"}}
{{- addImport "types" "UNSET_SENTINEL" true}}
@model_serializer(mode="wrap")
def {{sanitizeSerializeMethodName $type}}(self, handler):
def {{sanitizeSerializeMethodName $type}}(self, handler, info):
{{- $nullDefaultFields := templateNullDefaultFieldList $type}}
{{- $hasNullDefaultField := gt (len $nullDefaultFields) 0}}
{{- if $hasOptionalField}}
Expand All @@ -92,7 +92,9 @@ class {{$className}}(BaseModel):
m = {}

for n, f in type(self).model_fields.items():
k = f.alias or n
k = f.alias if f.alias is not None else n
if k not in serialized and n not in serialized and not info.exclude_none:
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
continue
val = serialized.get(k, serialized.get(n))
{{- if $hasAdditionalProperties}}
serialized.pop(k, serialized.pop(n, None))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import json
from typing import List, Optional

from pydantic import TypeAdapter

from openapi import utils
from openapi.models import errors, shared
from openapi.types import Nullable


def filtered_fields(display_name="name", **kwargs):
return shared.FilteredFields(
display_name=display_name, required_nullable=None, **kwargs
)


class TestModelDumpFilters:
def test_exclude_leaves_required_field_out(self):
model = filtered_fields()

assert "displayName" not in model.model_dump(exclude={"display_name"})
assert "displayName" not in model.model_dump(
by_alias=True, exclude={"display_name"}
)
assert "displayName" not in json.loads(
model.model_dump_json(exclude={"display_name"})
)

def test_include_leaves_other_fields_out(self):
model = filtered_fields(optional_nullable="x")

assert model.model_dump(include={"optional_nullable"}) == {
"optionalNullable": "x"
}
assert model.model_dump(by_alias=True, include={"display_name"}) == {
"displayName": "name"
}

def test_exclude_unset_leaves_unset_fields_out(self):
model = filtered_fields()

assert model.model_dump(exclude_unset=True) == {
"displayName": "name",
"requiredNullable": None,
}

def test_exclude_defaults_leaves_default_fields_out(self):
model = filtered_fields(with_default="other")

assert model.model_dump(exclude_defaults=True) == {
"displayName": "name",
"requiredNullable": None,
"withDefault": "other",
}

def test_exclude_none_still_writes_back_excluded_required_field(self):
model = filtered_fields()

dumped = model.model_dump(exclude={"display_name"}, exclude_none=True)

assert dumped["displayName"] is None

def test_nested_exclude(self):
model = filtered_fields(
child=filtered_fields("child"), children=[filtered_fields("item")]
)

dumped = model.model_dump(
exclude={
"child": {"display_name"},
"children": {"__all__": {"display_name"}},
}
)

assert dumped["displayName"] == "name"
assert "displayName" not in dumped["child"]
assert "displayName" not in dumped["children"][0]


class TestNestedDefaultDumps:
def test_nested_models_keep_api_names(self):
model = filtered_fields(
child=filtered_fields("child"), children=[filtered_fields("item")]
)

for dumped in (model.model_dump(), json.loads(model.model_dump_json())):
assert dumped["child"]["displayName"] == "child"
assert dumped["child"]["requiredNullable"] is None
assert dumped["children"][0]["displayName"] == "item"

def test_model_lists_keep_api_names(self):
adapter = TypeAdapter(List[shared.FilteredFields])

dumped = adapter.dump_python([filtered_fields("first")])

assert dumped[0]["displayName"] == "first"
assert dumped[0]["requiredNullable"] is None


class TestRequestBodies:
def test_explicit_nulls(self):
model = filtered_fields(optional_nullable=None)

assert (
utils.marshal_json(model, shared.FilteredFields)
== '{"displayName":"name","requiredNullable":null,"kind":"filtered","nullDefault":null,"optionalNullable":null,"withDefault":"fallback"}'
)

def test_required_nullable_null(self):
model = shared.PaginationCursorNullableLimitNextCursor(
next_cursor=None, results=["a"]
)

assert (
utils.marshal_json(model, shared.PaginationCursorNullableLimitNextCursor)
== '{"next_cursor":null,"results":["a"]}'
)

def test_nested_nulls_and_api_names(self):
model = filtered_fields(
child=filtered_fields("child", optional_nullable=None),
children=[filtered_fields("item")],
)

assert json.loads(utils.marshal_json(model, shared.FilteredFields)) == {
"displayName": "name",
"requiredNullable": None,
"child": {
"displayName": "child",
"requiredNullable": None,
"kind": "filtered",
"nullDefault": None,
"optionalNullable": None,
"withDefault": "fallback",
},
"children": [
{
"displayName": "item",
"requiredNullable": None,
"kind": "filtered",
"nullDefault": None,
"withDefault": "fallback",
}
],
"kind": "filtered",
"nullDefault": None,
"withDefault": "fallback",
}

def test_null_additional_property_omitted(self):
model = shared.ObjWithAnyAdditionalProperties.model_validate(
{"normalField": "x", "future": None}
)

assert (
utils.marshal_json(model, shared.ObjWithAnyAdditionalProperties)
== '{"normalField":"x"}'
)

def test_additional_property_under_python_name(self):
model = shared.ObjWithAnyAdditionalProperties.model_validate(
{"normalField": None, "normal_field": "extra"}
)
other = shared.FilteredFieldsWithAdditionalProperties.model_validate(
{"displayName": None, "display_name": "extra"}
)

assert (
utils.marshal_json(model, shared.ObjWithAnyAdditionalProperties)
== '{"normalField":"extra"}'
)
assert (
utils.marshal_json(other, shared.FilteredFieldsWithAdditionalProperties)
== '{"displayName":"extra"}'
)

def test_top_level_none(self):
assert utils.marshal_json(None, Optional[shared.FilteredFields]) == ""
assert utils.marshal_json(None, Nullable[shared.FilteredFields]) == "null"


class TestErrorData:
def test_plain_filtering(self):
data = errors.FilteredFieldsErrorData()

assert data.model_dump() == {"detail": None}
assert data.model_dump(exclude_none=True) == {}
assert data.model_dump(exclude_unset=True) == {}

def test_nested_model_honours_exclude(self):
data = errors.FilteredFieldsErrorData(detail=filtered_fields())

dumped = data.model_dump(exclude={"detail": {"display_name"}})

assert "displayName" not in dumped["detail"]
assert dumped["detail"]["requiredNullable"] is None


class TestEmptyAlias:
def test_value_kept_under_empty_api_name(self):
model = shared.BinaryString(file_name="a", content=b"x")

assert model.model_dump(by_alias=True)[""] == b"x"
assert model.model_dump()[""] == b"x"
assert "content" not in model.model_dump(by_alias=True)
assert "" not in model.model_dump(by_alias=True, exclude={"content"})
86 changes: 86 additions & 0 deletions tests/specs/fragments/primary/model-dump-filters.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
openapi: 3.1.0
security:
- apiKeyAuth: []
- oauth2: []
- clientCredentials: [read, write]
- customSchemeAppId: []
- basicHttp: []
- accessToken: []
- {}
paths:
/errors/model-dump-filters:
get:
operationId: getFilteredFieldsError
description: >-
Declares an error response whose body nests an object schema with
required, nullable, defaulted and const properties, so that serializing
the error body with fields filtered out can be checked against the same
rules as ordinary models.
tags:
- errors
responses:
"200":
description: OK
"400":
description: Error whose body nests FilteredFields
content:
application/json:
schema:
$ref: "#/components/schemas/FilteredFieldsError"
components:
schemas:
FilteredFields:
x-speakeasy-include: true
type: object
description: >-
Object with a required renamed property, required and optional nullable
properties, a property with a default, a nullable property with a null
default, a const property, and nested copies of itself as a property and
as array items. Fields that a serializer is asked to leave out (by
explicit selection, because they were never set, or because they hold
their default) must stay out of the output, while request bodies keep
sending required and explicitly set nullable properties as null.
required:
- displayName
- requiredNullable
- kind
properties:
displayName:
type: string
requiredNullable:
type: [string, "null"]
optionalNullable:
type: [string, "null"]
withDefault:
type: string
default: fallback
nullDefault:
type: [string, "null"]
default: null
kind:
type: string
const: filtered
child:
$ref: "#/components/schemas/FilteredFields"
children:
type: array
items:
$ref: "#/components/schemas/FilteredFields"
FilteredFieldsWithAdditionalProperties:
x-speakeasy-include: true
type: object
description: >-
Object with an optional nullable renamed property and untyped
additional properties, for request bodies that hold both the renamed
property and an additional property keyed by the property's other
spelling.
properties:
displayName:
type: [string, "null"]
additionalProperties: true
FilteredFieldsError:
type: object
description: Error body that nests FilteredFields.
properties:
detail:
$ref: "#/components/schemas/FilteredFields"
Loading
Loading