From 5961a8c61c795ee5a9a238cdfd3848e40161a38d Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Wed, 18 Jun 2025 16:49:10 -0500 Subject: [PATCH 01/25] Better jsonable encoder for iterables --- fastapi/encoders.py | 22 ++++++++++++++++++---- tests/test_jsonable_encoder.py | 17 ++++++++++++++++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 451ea0760..4b4133c7f 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -14,14 +14,26 @@ from ipaddress import ( from pathlib import Path, PurePath from re import Pattern from types import GeneratorType -from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union +from typing import ( + Any, + Callable, + Dict, + List, + Mapping, + Optional, + Sequence, + Tuple, + Type, + Union, +) from uuid import UUID from fastapi.types import IncEx from pydantic import BaseModel -from pydantic.color import Color from pydantic.networks import AnyUrl, NameEmail from pydantic.types import SecretBytes, SecretStr +from pydantic_extra_types.color import Color +from pydantic_extra_types.coordinate import Coordinate from typing_extensions import Annotated, Doc from ._compat import PYDANTIC_V2, UndefinedType, Url, _model_dump @@ -58,6 +70,7 @@ def decimal_encoder(dec_value: Decimal) -> Union[int, float]: ENCODERS_BY_TYPE: Dict[Type[Any], Callable[[Any], Any]] = { bytes: lambda o: o.decode(), Color: str, + Coordinate: str, datetime.date: isoformat, datetime.datetime: isoformat, datetime.time: isoformat, @@ -261,7 +274,7 @@ def jsonable_encoder( return obj if isinstance(obj, UndefinedType): return None - if isinstance(obj, dict): + if isinstance(obj, Mapping): encoded_dict = {} allowed_keys = set(obj.keys()) if include is not None: @@ -296,7 +309,8 @@ def jsonable_encoder( ) encoded_dict[encoded_key] = encoded_value return encoded_dict - if isinstance(obj, (list, set, frozenset, GeneratorType, tuple, deque)): + + if isinstance(obj, (Sequence, GeneratorType)): encoded_list = [] for item in obj: encoded_list.append( diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index 1906d6bf1..e112a1681 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone from decimal import Decimal from enum import Enum from pathlib import PurePath, PurePosixPath, PureWindowsPath -from typing import Optional +from typing import Optional, Sequence, Union import pytest from fastapi._compat import PYDANTIC_V2, Undefined @@ -316,3 +316,18 @@ def test_encode_deque_encodes_child_models(): def test_encode_pydantic_undefined(): data = {"value": Undefined} assert jsonable_encoder(data) == {"value": None} + + +def test_encode_sequence(): + class SequenceModel(Sequence[str]): + def __init__(self, items: list[str]): + self._items = items + + def __getitem__(self, index: Union[int, slice]) -> Union[str, Sequence[str]]: + return self._items[index] + + def __len__(self) -> int: + return len(self._items) + + seq = SequenceModel(["item1", "item2", "item3"]) + assert jsonable_encoder(seq) == ["item1", "item2", "item3"] From fe7af11a78ea96eaa2d2b78194b162fba9334d44 Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Wed, 2 Jul 2025 22:39:45 -0500 Subject: [PATCH 02/25] Update encoders.py --- fastapi/encoders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 4b4133c7f..a4d2e7569 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -30,9 +30,9 @@ from uuid import UUID from fastapi.types import IncEx from pydantic import BaseModel +from pydantic.color import Color from pydantic.networks import AnyUrl, NameEmail from pydantic.types import SecretBytes, SecretStr -from pydantic_extra_types.color import Color from pydantic_extra_types.coordinate import Coordinate from typing_extensions import Annotated, Doc From 334bb918e4150f4e91240bcdb793811af95f56d1 Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Tue, 10 Feb 2026 08:03:06 -0500 Subject: [PATCH 03/25] Update encoders.py --- fastapi/encoders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index d53d29117..742250c80 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -324,7 +324,7 @@ def jsonable_encoder( return encoded_dict if isinstance(obj, (Sequence, GeneratorType)): - encoded_list: List[Any] = [] + encoded_list = [] for item in obj: encoded_list.append( jsonable_encoder( From 4b23d869213826f842b7f998511a3d14285c4811 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:05:46 +0000 Subject: [PATCH 04/25] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/encoders.py | 9 ++------- tests/test_jsonable_encoder.py | 3 ++- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 08fdae973..a1f8e4ac0 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -1,6 +1,7 @@ import dataclasses import datetime from collections import defaultdict, deque +from collections.abc import Mapping, Sequence from decimal import Decimal from enum import Enum from ipaddress import ( @@ -18,13 +19,7 @@ from typing import ( Annotated, Any, Callable, - Dict, - List, - Mapping, Optional, - Sequence, - Tuple, - Type, Union, ) from uuid import UUID @@ -36,8 +31,8 @@ from pydantic import BaseModel from pydantic.color import Color from pydantic.networks import AnyUrl, NameEmail from pydantic.types import SecretBytes, SecretStr -from pydantic_extra_types.coordinate import Coordinate from pydantic_core import PydanticUndefinedType +from pydantic_extra_types.coordinate import Coordinate from ._compat import ( Url, diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index 97d49ce21..8860f30a1 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -1,12 +1,13 @@ import warnings from collections import deque +from collections.abc import Sequence from dataclasses import dataclass from datetime import datetime, timezone from decimal import Decimal from enum import Enum from math import isinf, isnan from pathlib import PurePath, PurePosixPath, PureWindowsPath -from typing import Optional, Sequence, Union, TypedDict +from typing import Optional, TypedDict, Union import pytest from fastapi._compat import Undefined From d23967f7564f87a99590e6c71cf221984c6c8957 Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Tue, 10 Feb 2026 08:08:35 -0500 Subject: [PATCH 05/25] Attempt to use pydantic_extra_types and allow the deprecated v1 Color --- fastapi/encoders.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index a1f8e4ac0..7e117636b 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -28,7 +28,10 @@ from annotated_doc import Doc from fastapi.exceptions import PydanticV1NotSupportedError from fastapi.types import IncEx from pydantic import BaseModel -from pydantic.color import Color +try: + from pydantic_extra_types.color import Color +except ImportError: + from pydantic.color import Color from pydantic.networks import AnyUrl, NameEmail from pydantic.types import SecretBytes, SecretStr from pydantic_core import PydanticUndefinedType @@ -76,7 +79,6 @@ ENCODERS_BY_TYPE: dict[type[Any], Callable[[Any], Any]] = { bytes: lambda o: o.decode(), Color: str, Coordinate: str, - may_v1.Color: str, datetime.date: isoformat, datetime.datetime: isoformat, datetime.time: isoformat, From 5c51d0be3c51debbcc65ad7456347f271485645d Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Tue, 10 Feb 2026 08:09:13 -0500 Subject: [PATCH 06/25] Update encoders.py --- fastapi/encoders.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 7e117636b..6ba5c2152 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -28,6 +28,7 @@ from annotated_doc import Doc from fastapi.exceptions import PydanticV1NotSupportedError from fastapi.types import IncEx from pydantic import BaseModel + try: from pydantic_extra_types.color import Color except ImportError: From da3a758974486a9c7b452e727aff32a80946b004 Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Tue, 10 Feb 2026 08:12:48 -0500 Subject: [PATCH 07/25] Revert import for color --- fastapi/encoders.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 6ba5c2152..6040a1c2b 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -28,11 +28,7 @@ from annotated_doc import Doc from fastapi.exceptions import PydanticV1NotSupportedError from fastapi.types import IncEx from pydantic import BaseModel - -try: - from pydantic_extra_types.color import Color -except ImportError: - from pydantic.color import Color +from pydantic.color import Color from pydantic.networks import AnyUrl, NameEmail from pydantic.types import SecretBytes, SecretStr from pydantic_core import PydanticUndefinedType From ad205367127dde02e9560e92038c1127a4531a4a Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:25:36 -0500 Subject: [PATCH 08/25] Update behaviour for named tuples --- fastapi/encoders.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 6040a1c2b..481bb8239 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -206,6 +206,18 @@ def jsonable_encoder( """ ), ] = True, + named_tuple_as_dict: Annotated[ + bool, + Doc( + """ + Whether to encode named tuples as dicts instead of lists. + + This is useful when you want to preserve the field names of named tuples + in the JSON output, which can make it easier to understand and work with + the data on the client side. + """ + ), + ] = False, ) -> Any: """ Convert any object to something that can be encoded in JSON. @@ -323,6 +335,19 @@ def jsonable_encoder( ) return encoded_list + if named_tuple_as_dict and getattr(obj, "_asdict", None) is not None and callable(obj._asdict): + return jsonable_encoder( + obj._asdict(), + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + custom_encoder=custom_encoder, + sqlalchemy_safe=sqlalchemy_safe, + ) + if type(obj) in ENCODERS_BY_TYPE: return ENCODERS_BY_TYPE[type(obj)](obj) for encoder, classes_tuple in encoders_by_class_tuples.items(): From 81bec6a275ec34a83f6ded29c3151ba024c1b80e Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:25:51 -0500 Subject: [PATCH 09/25] Coordinate is not a required type, making it optional --- fastapi/encoders.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 481bb8239..453bd3556 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -32,7 +32,11 @@ from pydantic.color import Color from pydantic.networks import AnyUrl, NameEmail from pydantic.types import SecretBytes, SecretStr from pydantic_core import PydanticUndefinedType -from pydantic_extra_types.coordinate import Coordinate + +try: + from pydantic_extra_types.coordinate import Coordinate +except ImportError: + Coordinate = dict from ._compat import ( Url, From cb3a8ca0199c86e8190a76377506cbdcbef3c38c Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:52:05 -0500 Subject: [PATCH 10/25] Final fix so tests works nicely --- fastapi/encoders.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 453bd3556..879def72e 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -321,7 +321,9 @@ def jsonable_encoder( encoded_dict[encoded_key] = encoded_value return encoded_dict - if isinstance(obj, (Sequence, GeneratorType)): + # Note that we check for `Sequence` and not `list` because we want to support any kind of sequence, like `list`, `tuple`, `set`, etc. + # Also, we check that it's not a `bytes` object, because `bytes` is also a `Sequence`, but we want to rely on the TYPE_ENCODERS for `bytes` and avoid code duplication. + if isinstance(obj, (Sequence, GeneratorType)) and not isinstance(obj, bytes): encoded_list = [] for item in obj: encoded_list.append( From 3c1866474dea4c55d50cff8cc68412326f44377c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:54:40 +0000 Subject: [PATCH 11/25] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/encoders.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 879def72e..16c90b82b 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -341,7 +341,11 @@ def jsonable_encoder( ) return encoded_list - if named_tuple_as_dict and getattr(obj, "_asdict", None) is not None and callable(obj._asdict): + if ( + named_tuple_as_dict + and getattr(obj, "_asdict", None) is not None + and callable(obj._asdict) + ): return jsonable_encoder( obj._asdict(), include=include, From 74417cec5caf6c58b2cf491e18abd0778b4af8a3 Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:04:37 -0500 Subject: [PATCH 12/25] For extra-types safety --- fastapi/encoders.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 16c90b82b..207f4900a 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -34,9 +34,15 @@ from pydantic.types import SecretBytes, SecretStr from pydantic_core import PydanticUndefinedType try: - from pydantic_extra_types.coordinate import Coordinate + from pydantic_extra_types import color as et_color + from pydantic_extra_types import coordinate + + encoders_by_extra_type: dict[type[Any], Callable[[Any], Any]] = { + coordinate.Coordinate: str, + et_color.Color: str + } except ImportError: - Coordinate = dict + encoders_by_extra_type = {} from ._compat import ( Url, @@ -79,7 +85,6 @@ def decimal_encoder(dec_value: Decimal) -> Union[int, float]: ENCODERS_BY_TYPE: dict[type[Any], Callable[[Any], Any]] = { bytes: lambda o: o.decode(), Color: str, - Coordinate: str, datetime.date: isoformat, datetime.datetime: isoformat, datetime.time: isoformat, @@ -119,6 +124,7 @@ def generate_encoders_by_class_tuples( encoders_by_class_tuples = generate_encoders_by_class_tuples(ENCODERS_BY_TYPE) +encoders_by_class_tuples.update(generate_encoders_by_class_tuples(encoders_by_extra_type)) def jsonable_encoder( @@ -358,6 +364,8 @@ def jsonable_encoder( sqlalchemy_safe=sqlalchemy_safe, ) + if type(obj) in encoders_by_extra_type: + return encoders_by_extra_type[type(obj)](obj) if type(obj) in ENCODERS_BY_TYPE: return ENCODERS_BY_TYPE[type(obj)](obj) for encoder, classes_tuple in encoders_by_class_tuples.items(): From ef5884603e9a7c2299ae83e902927b858674597c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:06:05 +0000 Subject: [PATCH 13/25] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/encoders.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 207f4900a..c9d1f2558 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -39,7 +39,7 @@ try: encoders_by_extra_type: dict[type[Any], Callable[[Any], Any]] = { coordinate.Coordinate: str, - et_color.Color: str + et_color.Color: str, } except ImportError: encoders_by_extra_type = {} @@ -124,7 +124,9 @@ def generate_encoders_by_class_tuples( encoders_by_class_tuples = generate_encoders_by_class_tuples(ENCODERS_BY_TYPE) -encoders_by_class_tuples.update(generate_encoders_by_class_tuples(encoders_by_extra_type)) +encoders_by_class_tuples.update( + generate_encoders_by_class_tuples(encoders_by_extra_type) +) def jsonable_encoder( From a6203ca51bed38f86b2753449c09bfc85e6a590f Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:21:35 -0500 Subject: [PATCH 14/25] Fix tests, two encoders for one --- fastapi/encoders.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index c9d1f2558..da3f8a8a3 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -123,10 +123,7 @@ def generate_encoders_by_class_tuples( return encoders_by_class_tuples -encoders_by_class_tuples = generate_encoders_by_class_tuples(ENCODERS_BY_TYPE) -encoders_by_class_tuples.update( - generate_encoders_by_class_tuples(encoders_by_extra_type) -) +encoders_by_class_tuples = generate_encoders_by_class_tuples(ENCODERS_BY_TYPE | encoders_by_extra_type) def jsonable_encoder( From e7c7b02a826c76aad4530687b8ee7ae7e4e84e34 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:27:42 +0000 Subject: [PATCH 15/25] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/encoders.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index da3f8a8a3..373dc2c0f 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -123,7 +123,9 @@ def generate_encoders_by_class_tuples( return encoders_by_class_tuples -encoders_by_class_tuples = generate_encoders_by_class_tuples(ENCODERS_BY_TYPE | encoders_by_extra_type) +encoders_by_class_tuples = generate_encoders_by_class_tuples( + ENCODERS_BY_TYPE | encoders_by_extra_type +) def jsonable_encoder( From 29ccbbc119657d66cd5282430587acf9c104f614 Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:53:20 -0500 Subject: [PATCH 16/25] Tests for coverage and sqlalchemy * avoid connection issues * Add tests for coverage (coordinate) --- fastapi/encoders.py | 37 ++++++------ tests/test_jsonable_encoder.py | 104 ++++++++++++++++++++++++++++++++- 2 files changed, 121 insertions(+), 20 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 373dc2c0f..4e64fa7de 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -38,7 +38,7 @@ try: from pydantic_extra_types import coordinate encoders_by_extra_type: dict[type[Any], Callable[[Any], Any]] = { - coordinate.Coordinate: str, + coordinate.Coordinate: lambda o: {"latitude": o.latitude, "longitude": o.longitude}, et_color.Color: str, } except ImportError: @@ -328,6 +328,24 @@ def jsonable_encoder( encoded_dict[encoded_key] = encoded_value return encoded_dict + # Check if it's a named tuple, and if so, encode it as a dict (instead of a list) if `named_tuple_as_dict` is `True`. + if ( + named_tuple_as_dict + and getattr(obj, "_asdict", None) is not None + and callable(obj._asdict) + ): + return jsonable_encoder( + obj._asdict(), + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + custom_encoder=custom_encoder, + sqlalchemy_safe=sqlalchemy_safe, + ) + # Note that we check for `Sequence` and not `list` because we want to support any kind of sequence, like `list`, `tuple`, `set`, etc. # Also, we check that it's not a `bytes` object, because `bytes` is also a `Sequence`, but we want to rely on the TYPE_ENCODERS for `bytes` and avoid code duplication. if isinstance(obj, (Sequence, GeneratorType)) and not isinstance(obj, bytes): @@ -348,23 +366,6 @@ def jsonable_encoder( ) return encoded_list - if ( - named_tuple_as_dict - and getattr(obj, "_asdict", None) is not None - and callable(obj._asdict) - ): - return jsonable_encoder( - obj._asdict(), - include=include, - exclude=exclude, - by_alias=by_alias, - exclude_unset=exclude_unset, - exclude_defaults=exclude_defaults, - exclude_none=exclude_none, - custom_encoder=custom_encoder, - sqlalchemy_safe=sqlalchemy_safe, - ) - if type(obj) in encoders_by_extra_type: return encoders_by_extra_type[type(obj)](obj) if type(obj) in ENCODERS_BY_TYPE: diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index 8860f30a1..55613d200 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -1,5 +1,5 @@ import warnings -from collections import deque +from collections import deque, namedtuple from collections.abc import Sequence from dataclasses import dataclass from datetime import datetime, timezone @@ -7,7 +7,7 @@ from decimal import Decimal from enum import Enum from math import isinf, isnan from pathlib import PurePath, PurePosixPath, PureWindowsPath -from typing import Optional, TypedDict, Union +from typing import NamedTuple, Optional, TypedDict, Union import pytest from fastapi._compat import Undefined @@ -326,4 +326,104 @@ def test_encode_sequence(): return len(self._items) seq = SequenceModel(["item1", "item2", "item3"]) + assert len(seq) == 3 assert jsonable_encoder(seq) == ["item1", "item2", "item3"] + + +def test_encode_bytes(): + assert jsonable_encoder(b"hello") == "hello" + + +def test_encode_bytes_in_dict(): + data = {"content": b"hello", "name": "test"} + assert jsonable_encoder(data) == {"content": "hello", "name": "test"} + + +def test_encode_list_of_bytes(): + data = [b"hello", b"world"] + assert jsonable_encoder(data) == ["hello", "world"] + + +def test_encode_generator(): + def gen(): + yield 1 + yield 2 + yield 3 + + assert jsonable_encoder(gen()) == [1, 2, 3] + + +def test_encode_generator_of_bytes(): + def gen(): + yield b"hello" + yield b"world" + + assert jsonable_encoder(gen()) == ["hello", "world"] + + +def test_encode_named_tuple_as_list(): + Point = namedtuple("Point", ["x", "y"]) + p = Point(1, 2) + assert jsonable_encoder(p) == [1, 2] + + +def test_encode_named_tuple_as_dict(): + Point = namedtuple("Point", ["x", "y"]) + p = Point(1, 2) + assert jsonable_encoder(p, named_tuple_as_dict=True) == {"x": 1, "y": 2} + + +def test_encode_typed_named_tuple_as_list(): + class Point(NamedTuple): + x: int + y: int + + p = Point(1, 2) + assert jsonable_encoder(p) == [1, 2] + + +def test_encode_typed_named_tuple_as_dict(): + class Point(NamedTuple): + x: int + y: int + + p = Point(1, 2) + assert jsonable_encoder(p, named_tuple_as_dict=True) == {"x": 1, "y": 2} + + +def test_encode_sqlalchemy_safe_filters_sa_keys(): + data = {"name": "test", "_sa_instance_state": "internal"} + assert jsonable_encoder(data, sqlalchemy_safe=True) == {"name": "test"} + assert jsonable_encoder(data, sqlalchemy_safe=False) == { + "name": "test", + "_sa_instance_state": "internal", + } + + +def test_encode_sqlalchemy_row_as_list(): + sa = pytest.importorskip("sqlalchemy") + engine = sa.create_engine("sqlite:///:memory:") + with engine.connect() as conn: + row = conn.execute(sa.text("SELECT 1 AS x, 2 AS y")).fetchone() + engine.dispose() + assert row is not None + assert jsonable_encoder(row) == [1, 2] + + +def test_encode_sqlalchemy_row_as_dict(): + sa = pytest.importorskip("sqlalchemy") + engine = sa.create_engine("sqlite:///:memory:") + with engine.connect() as conn: + row = conn.execute(sa.text("SELECT 1 AS x, 2 AS y")).fetchone() + engine.dispose() + assert row is not None + assert jsonable_encoder(row, named_tuple_as_dict=True) == {"x": 1, "y": 2} + + +def test_encode_pydantic_extra_types_coordinate(): + coordinate = pytest.importorskip("pydantic_extra_types.coordinate") + coord = coordinate.Coordinate(latitude=1.0, longitude=2.0) + assert jsonable_encoder(coord) != str(coord) + assert jsonable_encoder(coord) == {"latitude": 1.0, "longitude": 2.0} + + From a32387757ec0136ac447b0492bda0e3714f8ee1f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:46:27 +0000 Subject: [PATCH 17/25] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/encoders.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 4e64fa7de..66ab01b14 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -38,7 +38,10 @@ try: from pydantic_extra_types import coordinate encoders_by_extra_type: dict[type[Any], Callable[[Any], Any]] = { - coordinate.Coordinate: lambda o: {"latitude": o.latitude, "longitude": o.longitude}, + coordinate.Coordinate: lambda o: { + "latitude": o.latitude, + "longitude": o.longitude, + }, et_color.Color: str, } except ImportError: From 900936b42640ac28a39aec4885918a803a3be4d6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:03:21 +0000 Subject: [PATCH 18/25] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_jsonable_encoder.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index 55613d200..564b5d81d 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -425,5 +425,3 @@ def test_encode_pydantic_extra_types_coordinate(): coord = coordinate.Coordinate(latitude=1.0, longitude=2.0) assert jsonable_encoder(coord) != str(coord) assert jsonable_encoder(coord) == {"latitude": 1.0, "longitude": 2.0} - - From 3f3f98d196316577365127d78eae90b426f03eaf Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:20:11 -0500 Subject: [PATCH 19/25] More tests for coverage --- fastapi/encoders.py | 11 +++++------ tests/test_jsonable_encoder.py | 27 +++++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 66ab01b14..f9e309e31 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -38,10 +38,7 @@ try: from pydantic_extra_types import coordinate encoders_by_extra_type: dict[type[Any], Callable[[Any], Any]] = { - coordinate.Coordinate: lambda o: { - "latitude": o.latitude, - "longitude": o.longitude, - }, + coordinate.Coordinate: str, et_color.Color: str, } except ImportError: @@ -273,6 +270,10 @@ def jsonable_encoder( exclude_defaults=exclude_defaults, sqlalchemy_safe=sqlalchemy_safe, ) + # The extra types have their own encoders, so we check for them before checking for dataclasses, + # because some of them are also dataclasses, and we want to use their custom encoders instead of encoding them as dataclasses. + if type(obj) in encoders_by_extra_type: + return encoders_by_extra_type[type(obj)](obj) if dataclasses.is_dataclass(obj): assert not isinstance(obj, type) obj_dict = dataclasses.asdict(obj) @@ -369,8 +370,6 @@ def jsonable_encoder( ) return encoded_list - if type(obj) in encoders_by_extra_type: - return encoders_by_extra_type[type(obj)](obj) if type(obj) in ENCODERS_BY_TYPE: return ENCODERS_BY_TYPE[type(obj)](obj) for encoder, classes_tuple in encoders_by_class_tuples.items(): diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index 564b5d81d..5b65758c4 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -1,3 +1,4 @@ +import sys import warnings from collections import deque, namedtuple from collections.abc import Sequence @@ -423,5 +424,27 @@ def test_encode_sqlalchemy_row_as_dict(): def test_encode_pydantic_extra_types_coordinate(): coordinate = pytest.importorskip("pydantic_extra_types.coordinate") coord = coordinate.Coordinate(latitude=1.0, longitude=2.0) - assert jsonable_encoder(coord) != str(coord) - assert jsonable_encoder(coord) == {"latitude": 1.0, "longitude": 2.0} + # Dataclass output shouldn't be the result + assert jsonable_encoder(coord) != {"latitude": 1.0, "longitude": 2.0} + # The custom encoder should be the result + assert jsonable_encoder(coord) == str(coord) + + +@pytest.mark.skipif( + sys.version_info > (3, 9), + reason="Tested via pydantic_extra_types on Python > 3.9", +) +def test_encode_pydantic_color(): + pydantic_color = pytest.importorskip("pydantic.color") + color = pydantic_color.Color("red") + assert jsonable_encoder(color) == str(color) + + +@pytest.mark.skipif( + sys.version_info <= (3, 9), + reason="pydantic_extra_types.color not available on Python <= 3.9", +) +def test_encode_pydantic_extra_types_color(): + et_color = pytest.importorskip("pydantic_extra_types.color") + color = et_color.Color("red") + assert jsonable_encoder(color) == str(color) From bb29cf2912dd423b9414359f95486841e5859415 Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:31:46 -0500 Subject: [PATCH 20/25] Update for fix the version check --- tests/test_jsonable_encoder.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index 5b65758c4..e0153f07a 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -431,8 +431,8 @@ def test_encode_pydantic_extra_types_coordinate(): @pytest.mark.skipif( - sys.version_info > (3, 9), - reason="Tested via pydantic_extra_types on Python > 3.9", + sys.version_info >= (3, 10), + reason="Tested via pydantic_extra_types on Python >= 3.10", ) def test_encode_pydantic_color(): pydantic_color = pytest.importorskip("pydantic.color") @@ -441,8 +441,8 @@ def test_encode_pydantic_color(): @pytest.mark.skipif( - sys.version_info <= (3, 9), - reason="pydantic_extra_types.color not available on Python <= 3.9", + sys.version_info < (3, 10), + reason="pydantic_extra_types.color not available on Python < 3.10", ) def test_encode_pydantic_extra_types_color(): et_color = pytest.importorskip("pydantic_extra_types.color") From 5cdeeccaf9408e243b14199500be48f91eca822e Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:44:47 -0500 Subject: [PATCH 21/25] Fix mismatch on versions --- fastapi/encoders.py | 13 ++++++++----- tests/test_jsonable_encoder.py | 9 --------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index f9e309e31..a307228e0 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -33,16 +33,19 @@ from pydantic.networks import AnyUrl, NameEmail from pydantic.types import SecretBytes, SecretStr from pydantic_core import PydanticUndefinedType +encoders_by_extra_type: dict[type[Any], Callable[[Any], Any]] = {} try: from pydantic_extra_types import color as et_color + + encoders_by_extra_type[et_color.Color] = str +except ImportError: + pass +try: from pydantic_extra_types import coordinate - encoders_by_extra_type: dict[type[Any], Callable[[Any], Any]] = { - coordinate.Coordinate: str, - et_color.Color: str, - } + encoders_by_extra_type[coordinate.Coordinate] = str except ImportError: - encoders_by_extra_type = {} + pass from ._compat import ( Url, diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index e0153f07a..f7a8dd4a0 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -1,4 +1,3 @@ -import sys import warnings from collections import deque, namedtuple from collections.abc import Sequence @@ -430,20 +429,12 @@ def test_encode_pydantic_extra_types_coordinate(): assert jsonable_encoder(coord) == str(coord) -@pytest.mark.skipif( - sys.version_info >= (3, 10), - reason="Tested via pydantic_extra_types on Python >= 3.10", -) def test_encode_pydantic_color(): pydantic_color = pytest.importorskip("pydantic.color") color = pydantic_color.Color("red") assert jsonable_encoder(color) == str(color) -@pytest.mark.skipif( - sys.version_info < (3, 10), - reason="pydantic_extra_types.color not available on Python < 3.10", -) def test_encode_pydantic_extra_types_color(): et_color = pytest.importorskip("pydantic_extra_types.color") color = et_color.Color("red") From 7d26de7ed94c4a525302cbb01e56fe0ad439c8ff Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:49:00 -0500 Subject: [PATCH 22/25] E402 Module level import not at top of file --- fastapi/encoders.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index a307228e0..2b38e4134 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -33,6 +33,11 @@ from pydantic.networks import AnyUrl, NameEmail from pydantic.types import SecretBytes, SecretStr from pydantic_core import PydanticUndefinedType +from ._compat import ( + Url, + is_pydantic_v1_model_instance, +) + encoders_by_extra_type: dict[type[Any], Callable[[Any], Any]] = {} try: from pydantic_extra_types import color as et_color @@ -47,11 +52,6 @@ try: except ImportError: pass -from ._compat import ( - Url, - is_pydantic_v1_model_instance, -) - # Taken from Pydantic v1 as is def isoformat(o: Union[datetime.date, datetime.time]) -> str: From c78d6594877cea3ac7a3456ed77f58507256bccf Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:55:33 -0500 Subject: [PATCH 23/25] Deprecated Color --- tests/test_jsonable_encoder.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index f7a8dd4a0..a8648cef4 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -429,12 +429,6 @@ def test_encode_pydantic_extra_types_coordinate(): assert jsonable_encoder(coord) == str(coord) -def test_encode_pydantic_color(): - pydantic_color = pytest.importorskip("pydantic.color") - color = pydantic_color.Color("red") - assert jsonable_encoder(color) == str(color) - - def test_encode_pydantic_extra_types_color(): et_color = pytest.importorskip("pydantic_extra_types.color") color = et_color.Color("red") From d7a678823094421e3bc0ccf3aa8230742c30a57e Mon Sep 17 00:00:00 2001 From: Pedro Lobato <69770518+Lob26@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:13:14 -0500 Subject: [PATCH 24/25] As pydantic v1 support is dropped, we can "inline" the color encoder --- fastapi/encoders.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 2b38e4134..584473ed1 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -33,18 +33,16 @@ from pydantic.networks import AnyUrl, NameEmail from pydantic.types import SecretBytes, SecretStr from pydantic_core import PydanticUndefinedType +# Dropped support for Pydantic v1 so we can remove the try-except import and the related code +from pydantic_extra_types import color as et_color + from ._compat import ( Url, is_pydantic_v1_model_instance, ) -encoders_by_extra_type: dict[type[Any], Callable[[Any], Any]] = {} -try: - from pydantic_extra_types import color as et_color +encoders_by_extra_type: dict[type[Any], Callable[[Any], Any]] = {et_color.Color: str} - encoders_by_extra_type[et_color.Color] = str -except ImportError: - pass try: from pydantic_extra_types import coordinate From f4d9ef3131752b4da8bf7fa739dd802654fb5fde Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 13 Feb 2026 05:12:37 +0000 Subject: [PATCH 25/25] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/encoders.py | 5 +---- tests/test_jsonable_encoder.py | 6 +++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index a01ad46bf..c6fae1577 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -1,7 +1,7 @@ import dataclasses import datetime from collections import defaultdict, deque -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from decimal import Decimal from enum import Enum from ipaddress import ( @@ -18,9 +18,6 @@ from types import GeneratorType from typing import ( Annotated, Any, - Callable, - Optional, - Union, ) from uuid import UUID diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index a8648cef4..cab509aee 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -7,7 +7,7 @@ from decimal import Decimal from enum import Enum from math import isinf, isnan from pathlib import PurePath, PurePosixPath, PureWindowsPath -from typing import NamedTuple, Optional, TypedDict, Union +from typing import NamedTuple, TypedDict import pytest from fastapi._compat import Undefined @@ -58,7 +58,7 @@ class RoleEnum(Enum): class ModelWithConfig(BaseModel): - role: Optional[RoleEnum] = None + role: RoleEnum | None = None model_config = {"use_enum_values": True} @@ -319,7 +319,7 @@ def test_encode_sequence(): def __init__(self, items: list[str]): self._items = items - def __getitem__(self, index: Union[int, slice]) -> Union[str, Sequence[str]]: + def __getitem__(self, index: int | slice) -> str | Sequence[str]: return self._items[index] def __len__(self) -> int: