From 39c51a38ec42408297533e8c29482ee24af918ea Mon Sep 17 00:00:00 2001 From: Ben Brady Date: Sat, 9 Aug 2025 02:48:32 +0100 Subject: [PATCH 01/19] =?UTF-8?q?=F0=9F=94=A8=20refactored=20jsonable=5Fen?= =?UTF-8?q?coder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/encoders.py | 215 ++++++++++++++++++++++++++++---------------- 1 file changed, 136 insertions(+), 79 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 451ea0760..b43d372d2 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -75,11 +75,13 @@ ENCODERS_BY_TYPE: Dict[Type[Any], Callable[[Any], Any]] = { IPv6Network: str, NameEmail: str, Path: str, + PurePath: str, Pattern: lambda o: o.pattern, SecretBytes: str, SecretStr: str, set: list, UUID: str, + UndefinedType: lambda _: None, Url: str, AnyUrl: str, } @@ -98,6 +100,10 @@ def generate_encoders_by_class_tuples( encoders_by_class_tuples = generate_encoders_by_class_tuples(ENCODERS_BY_TYPE) +NoneType = type(None) +primitive_types = (str, int, float, NoneType) +iterable_types = (list, set, frozenset, GeneratorType, tuple, deque) + def jsonable_encoder( obj: Annotated[ @@ -201,18 +207,74 @@ def jsonable_encoder( Read more about it in the [FastAPI docs for JSON Compatible Encoder](https://fastapi.tiangolo.com/tutorial/encoder/). """ - custom_encoder = custom_encoder or {} - if custom_encoder: - if type(obj) in custom_encoder: - return custom_encoder[type(obj)](obj) - else: - for encoder_type, encoder_instance in custom_encoder.items(): - if isinstance(obj, encoder_type): - return encoder_instance(obj) if include is not None and not isinstance(include, (set, dict)): include = set(include) + if exclude is not None and not isinstance(exclude, (set, dict)): exclude = set(exclude) + + return encode_value( + obj, + 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, + ) + + +def encode_value( + obj: Any, + include: Optional[IncEx] = None, + exclude: Optional[IncEx] = None, + by_alias: bool = True, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + custom_encoder: Optional[Dict[Any, Callable[[Any], Any]]] = None, + sqlalchemy_safe: bool = True, +) -> Any: + if custom_encoder: + for encoder_type, encoder_instance in custom_encoder.items(): + if isinstance(obj, encoder_type): + return encoder_instance(obj) + + if isinstance(obj, primitive_types): + return obj + + if isinstance(obj, iterable_types): + encoded_list = [] + for item in obj: + value = encode_value( + item, + 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, + ) + encoded_list.append(value) + + return encoded_list + + if isinstance(obj, dict): + return encode_dict( + obj, + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_none=exclude_none, + custom_encoder=custom_encoder, + sqlalchemy_safe=sqlalchemy_safe, + ) + if isinstance(obj, BaseModel): # TODO: remove when deprecating Pydantic v1 encoders: Dict[Any, Any] = {} @@ -220,6 +282,7 @@ def jsonable_encoder( encoders = getattr(obj.__config__, "json_encoders", {}) # type: ignore[attr-defined] if custom_encoder: encoders.update(custom_encoder) + obj_dict = _model_dump( obj, mode="json", @@ -230,9 +293,11 @@ def jsonable_encoder( exclude_none=exclude_none, exclude_defaults=exclude_defaults, ) + if "__root__" in obj_dict: obj_dict = obj_dict["__root__"] - return jsonable_encoder( + + return encode_value( obj_dict, exclude_none=exclude_none, exclude_defaults=exclude_defaults, @@ -240,104 +305,96 @@ def jsonable_encoder( custom_encoder=encoders, sqlalchemy_safe=sqlalchemy_safe, ) + if dataclasses.is_dataclass(obj): - obj_dict = dataclasses.asdict(obj) - return jsonable_encoder( + obj_dict = dataclasses.asdict(obj) # type: ignore + return encode_dict( obj_dict, 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 isinstance(obj, Enum): - return obj.value - if isinstance(obj, PurePath): - return str(obj) - if isinstance(obj, (str, int, float, type(None))): - return obj - if isinstance(obj, UndefinedType): - return None - if isinstance(obj, dict): - encoded_dict = {} - allowed_keys = set(obj.keys()) - if include is not None: - allowed_keys &= set(include) - if exclude is not None: - allowed_keys -= set(exclude) - for key, value in obj.items(): - if ( - ( - not sqlalchemy_safe - or (not isinstance(key, str)) - or (not key.startswith("_sa")) - ) - and (value is not None or not exclude_none) - and key in allowed_keys - ): - encoded_key = jsonable_encoder( - key, - by_alias=by_alias, - exclude_unset=exclude_unset, - exclude_none=exclude_none, - custom_encoder=custom_encoder, - sqlalchemy_safe=sqlalchemy_safe, - ) - encoded_value = jsonable_encoder( - value, - by_alias=by_alias, - exclude_unset=exclude_unset, - exclude_none=exclude_none, - custom_encoder=custom_encoder, - sqlalchemy_safe=sqlalchemy_safe, - ) - encoded_dict[encoded_key] = encoded_value - return encoded_dict - if isinstance(obj, (list, set, frozenset, GeneratorType, tuple, deque)): - encoded_list = [] - for item in obj: - encoded_list.append( - jsonable_encoder( - item, - 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, - ) - ) - return encoded_list if type(obj) in ENCODERS_BY_TYPE: return ENCODERS_BY_TYPE[type(obj)](obj) + for encoder, classes_tuple in encoders_by_class_tuples.items(): if isinstance(obj, classes_tuple): return encoder(obj) try: - data = dict(obj) + obj_dict = dict(obj) except Exception as e: - errors: List[Exception] = [] - errors.append(e) + errors = [e] try: - data = vars(obj) + obj_dict = vars(obj) except Exception as e: errors.append(e) raise ValueError(errors) from e - return jsonable_encoder( - data, + + return encode_dict( + obj_dict, 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, ) + + +def encode_dict( + obj: Any, + include: Optional[IncEx] = None, + exclude: Optional[IncEx] = None, + by_alias: bool = True, + exclude_unset: bool = False, + exclude_none: bool = False, + custom_encoder: Optional[Dict[Any, Callable[[Any], Any]]] = None, + sqlalchemy_safe: bool = True, +) -> Any: + encoded_dict = {} + allowed_keys = set(obj.keys()) + + if include is not None: + allowed_keys &= set(include) + + if exclude is not None: + allowed_keys -= set(exclude) + + for key, value in obj.items(): + if sqlalchemy_safe and isinstance(key, str) and key.startswith("_sa"): + continue + if value is None and exclude_none: + continue + if key not in allowed_keys: + continue + + if isinstance(key, primitive_types): + encoded_key = key + else: + encoded_key = encode_value( + key, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_none=exclude_none, + custom_encoder=custom_encoder, + sqlalchemy_safe=sqlalchemy_safe, + ) + + encoded_value = encode_value( + value, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_none=exclude_none, + custom_encoder=custom_encoder, + sqlalchemy_safe=sqlalchemy_safe, + ) + encoded_dict[encoded_key] = encoded_value + + return encoded_dict From 9780f0e91460986bae4866c8e304c0a21cb081a4 Mon Sep 17 00:00:00 2001 From: Ben Brady Date: Sat, 9 Aug 2025 02:49:00 +0100 Subject: [PATCH 02/19] =?UTF-8?q?=F0=9F=94=A8=20added=20additional=20jsona?= =?UTF-8?q?ble=5Fencoder=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_jsonable_encoder.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index 1906d6bf1..e7e447c1d 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -88,6 +88,30 @@ def test_encode_dict(): } +def test_encode_dict_with_nonprimative_keys(): + class CustomString: + value: str + + def __init__(self, value: str) -> None: + self.value = value + + assert jsonable_encoder( + {CustomString("foo"): "bar"}, + custom_encoder={CustomString: lambda v: v.value} + ) == {"foo": "bar"} + + +def test_encode_dict_with_sqlalchemy_safe(): + obj = {"_sa_foo": "foo", "bar": "bar"} + assert jsonable_encoder(obj, sqlalchemy_safe=True) == {"bar": "bar"} + assert jsonable_encoder(obj, sqlalchemy_safe=False ) == obj + + +def test_encode_dict_with_exclude_none(): + assert jsonable_encoder({"foo": None}, exclude_none=True) == {} + assert jsonable_encoder({"foo": None}, exclude_none=False) == {"foo": None} + + def test_encode_class(): person = Person(name="Foo") pet = Pet(owner=person, name="Firulais") From c5454038d5d8795ddee95bee8085972530c08f3f Mon Sep 17 00:00:00 2001 From: Ben Brady Date: Sat, 9 Aug 2025 02:55:00 +0100 Subject: [PATCH 03/19] =?UTF-8?q?=F0=9F=94=A8=20tweaked=20encoder=20primat?= =?UTF-8?q?ive=20key=20skip=20to=20disallow=20subclasses?= 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 b43d372d2..41c589d49 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -375,7 +375,9 @@ def encode_dict( if key not in allowed_keys: continue - if isinstance(key, primitive_types): + # use type() in, instead of isinstance + # we don't want to allow subclasses, they should be encoded still + if type(key) in primitive_types: encoded_key = key else: encoded_key = encode_value( From b2111dbd4ca39c80f7177ea86473ef6859488200 Mon Sep 17 00:00:00 2001 From: Ben Brady Date: Sat, 9 Aug 2025 03:05:50 +0100 Subject: [PATCH 04/19] =?UTF-8?q?=F0=9F=94=A8=20removed=20primative=20type?= =?UTF-8?q?=20skip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/encoders.py | 21 ++++++++------------- tests/test_jsonable_encoder.py | 12 ++++++++++++ 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 41c589d49..c4a8bcf76 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -375,19 +375,14 @@ def encode_dict( if key not in allowed_keys: continue - # use type() in, instead of isinstance - # we don't want to allow subclasses, they should be encoded still - if type(key) in primitive_types: - encoded_key = key - else: - encoded_key = encode_value( - key, - by_alias=by_alias, - exclude_unset=exclude_unset, - exclude_none=exclude_none, - custom_encoder=custom_encoder, - sqlalchemy_safe=sqlalchemy_safe, - ) + encoded_key = encode_value( + key, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_none=exclude_none, + custom_encoder=custom_encoder, + sqlalchemy_safe=sqlalchemy_safe, + ) encoded_value = encode_value( value, diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index e7e447c1d..6b1b744eb 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -95,11 +95,23 @@ def test_encode_dict_with_nonprimative_keys(): def __init__(self, value: str) -> None: self.value = value + def __eq__(self, other) -> bool: + return isinstance(other, CustomString) and self.value == other.value + + def __hash__(self): + return hash(self.value) + assert jsonable_encoder( {CustomString("foo"): "bar"}, custom_encoder={CustomString: lambda v: v.value} ) == {"foo": "bar"} +def test_encode_dict_with_custom_encoder_keys(): + assert jsonable_encoder( + {"foo": "bar"}, + custom_encoder={str: lambda v: "_" + v} + ) == {"_foo": "_bar"} + def test_encode_dict_with_sqlalchemy_safe(): obj = {"_sa_foo": "foo", "bar": "bar"} From decaa3e7e319237f7f9ff0e4d8268209ad28034c Mon Sep 17 00:00:00 2001 From: Ben Brady Date: Sat, 9 Aug 2025 03:27:31 +0100 Subject: [PATCH 05/19] =?UTF-8?q?=F0=9F=94=A8=20inlined=20primative=5Ftype?= =?UTF-8?q?s=20and=20iterable=5Ftypes=20constants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/encoders.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index c4a8bcf76..5de25ca4c 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -101,9 +101,6 @@ def generate_encoders_by_class_tuples( encoders_by_class_tuples = generate_encoders_by_class_tuples(ENCODERS_BY_TYPE) NoneType = type(None) -primitive_types = (str, int, float, NoneType) -iterable_types = (list, set, frozenset, GeneratorType, tuple, deque) - def jsonable_encoder( obj: Annotated[ @@ -242,10 +239,10 @@ def encode_value( if isinstance(obj, encoder_type): return encoder_instance(obj) - if isinstance(obj, primitive_types): + if isinstance(obj, (str, int, float, NoneType)): return obj - if isinstance(obj, iterable_types): + if isinstance(obj, (list, set, frozenset, GeneratorType, tuple, deque)): encoded_list = [] for item in obj: value = encode_value( From dc9a03fc93056d2d3b09a3d09aeefcb36ab69ba7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 9 Aug 2025 02:33:12 +0000 Subject: [PATCH 06/19] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20f?= =?UTF-8?q?ormat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/encoders.py | 3 ++- tests/test_jsonable_encoder.py | 9 ++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 5de25ca4c..e1cab1863 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -14,7 +14,7 @@ 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, Optional, Tuple, Type, Union from uuid import UUID from fastapi.types import IncEx @@ -102,6 +102,7 @@ encoders_by_class_tuples = generate_encoders_by_class_tuples(ENCODERS_BY_TYPE) NoneType = type(None) + def jsonable_encoder( obj: Annotated[ Any, diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index 6b1b744eb..4d805c561 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -102,21 +102,20 @@ def test_encode_dict_with_nonprimative_keys(): return hash(self.value) assert jsonable_encoder( - {CustomString("foo"): "bar"}, - custom_encoder={CustomString: lambda v: v.value} + {CustomString("foo"): "bar"}, custom_encoder={CustomString: lambda v: v.value} ) == {"foo": "bar"} + def test_encode_dict_with_custom_encoder_keys(): assert jsonable_encoder( - {"foo": "bar"}, - custom_encoder={str: lambda v: "_" + v} + {"foo": "bar"}, custom_encoder={str: lambda v: "_" + v} ) == {"_foo": "_bar"} def test_encode_dict_with_sqlalchemy_safe(): obj = {"_sa_foo": "foo", "bar": "bar"} assert jsonable_encoder(obj, sqlalchemy_safe=True) == {"bar": "bar"} - assert jsonable_encoder(obj, sqlalchemy_safe=False ) == obj + assert jsonable_encoder(obj, sqlalchemy_safe=False) == obj def test_encode_dict_with_exclude_none(): From 1f86213ffcd538921c4d7742cf36f8b323a61dce Mon Sep 17 00:00:00 2001 From: Ben Brady Date: Sat, 9 Aug 2025 03:40:31 +0100 Subject: [PATCH 07/19] =?UTF-8?q?=E2=9A=92=EF=B8=8F=20format=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/encoders.py | 3 ++- tests/test_jsonable_encoder.py | 9 ++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 5de25ca4c..e1cab1863 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -14,7 +14,7 @@ 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, Optional, Tuple, Type, Union from uuid import UUID from fastapi.types import IncEx @@ -102,6 +102,7 @@ encoders_by_class_tuples = generate_encoders_by_class_tuples(ENCODERS_BY_TYPE) NoneType = type(None) + def jsonable_encoder( obj: Annotated[ Any, diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index 6b1b744eb..4d805c561 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -102,21 +102,20 @@ def test_encode_dict_with_nonprimative_keys(): return hash(self.value) assert jsonable_encoder( - {CustomString("foo"): "bar"}, - custom_encoder={CustomString: lambda v: v.value} + {CustomString("foo"): "bar"}, custom_encoder={CustomString: lambda v: v.value} ) == {"foo": "bar"} + def test_encode_dict_with_custom_encoder_keys(): assert jsonable_encoder( - {"foo": "bar"}, - custom_encoder={str: lambda v: "_" + v} + {"foo": "bar"}, custom_encoder={str: lambda v: "_" + v} ) == {"_foo": "_bar"} def test_encode_dict_with_sqlalchemy_safe(): obj = {"_sa_foo": "foo", "bar": "bar"} assert jsonable_encoder(obj, sqlalchemy_safe=True) == {"bar": "bar"} - assert jsonable_encoder(obj, sqlalchemy_safe=False ) == obj + assert jsonable_encoder(obj, sqlalchemy_safe=False) == obj def test_encode_dict_with_exclude_none(): From 8a9fd110f9d4c8d5e2c4d1db068cef0487fbe751 Mon Sep 17 00:00:00 2001 From: Ben Brady Date: Sat, 9 Aug 2025 12:58:51 +0100 Subject: [PATCH 08/19] =?UTF-8?q?=E2=9A=92=EF=B8=8F=20fixed=20lint=20error?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/encoders.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index e1cab1863..627cd151f 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -240,7 +240,7 @@ def encode_value( if isinstance(obj, encoder_type): return encoder_instance(obj) - if isinstance(obj, (str, int, float, NoneType)): + if isinstance(obj, (str, int, float, NoneType)): # type: ignore[arg-type, misc] return obj if isinstance(obj, (list, set, frozenset, GeneratorType, tuple, deque)): @@ -305,7 +305,7 @@ def encode_value( ) if dataclasses.is_dataclass(obj): - obj_dict = dataclasses.asdict(obj) # type: ignore + obj_dict = dataclasses.asdict(obj) return encode_dict( obj_dict, include=include, From 21e96381cf712afd5056f7a69adcb19e661b6a5f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 9 Aug 2025 11:59:02 +0000 Subject: [PATCH 09/19] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20f?= =?UTF-8?q?ormat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/encoders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 627cd151f..293814413 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -240,7 +240,7 @@ def encode_value( if isinstance(obj, encoder_type): return encoder_instance(obj) - if isinstance(obj, (str, int, float, NoneType)): # type: ignore[arg-type, misc] + if isinstance(obj, (str, int, float, NoneType)): # type: ignore[arg-type, misc] return obj if isinstance(obj, (list, set, frozenset, GeneratorType, tuple, deque)): From 97ce555b418488e2a986aedd84157508cf1bce49 Mon Sep 17 00:00:00 2001 From: Ben Brady Date: Sat, 9 Aug 2025 13:14:45 +0100 Subject: [PATCH 10/19] =?UTF-8?q?=F0=9F=94=A8=20removed=20unused=20test=20?= =?UTF-8?q?code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_jsonable_encoder.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index 4d805c561..b1cb56c65 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -95,9 +95,6 @@ def test_encode_dict_with_nonprimative_keys(): def __init__(self, value: str) -> None: self.value = value - def __eq__(self, other) -> bool: - return isinstance(other, CustomString) and self.value == other.value - def __hash__(self): return hash(self.value) From 028d681000eb010522e5e1286067aaf8a71b4643 Mon Sep 17 00:00:00 2001 From: Ben Brady Date: Sat, 9 Aug 2025 18:48:13 +0100 Subject: [PATCH 11/19] =?UTF-8?q?=F0=9F=94=A8=20added=20back=20custom=5Fen?= =?UTF-8?q?coder=20direct=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/encoders.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 293814413..acbd94e47 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -236,6 +236,9 @@ def encode_value( sqlalchemy_safe: bool = True, ) -> Any: if custom_encoder: + if type(obj) in custom_encoder: + return custom_encoder[type(obj)](obj) + for encoder_type, encoder_instance in custom_encoder.items(): if isinstance(obj, encoder_type): return encoder_instance(obj) From f3a01033d6d8c26e1a1c9aa1058d174433799cef Mon Sep 17 00:00:00 2001 From: Ben Brady Date: Thu, 21 Aug 2025 22:10:29 +0100 Subject: [PATCH 12/19] =?UTF-8?q?=F0=9F=94=A8=20changed=20encode=5Fdict=20?= =?UTF-8?q?parameter=20typing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/encoders.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index acbd94e47..df6990089 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -350,7 +350,7 @@ def encode_value( def encode_dict( - obj: Any, + obj: Dict[Any, Any], include: Optional[IncEx] = None, exclude: Optional[IncEx] = None, by_alias: bool = True, @@ -375,7 +375,6 @@ def encode_dict( continue if key not in allowed_keys: continue - encoded_key = encode_value( key, by_alias=by_alias, From 9266fe33578fefe6b727242f805d4fdf8ec48837 Mon Sep 17 00:00:00 2001 From: Ben Brady Date: Thu, 21 Aug 2025 23:14:47 +0100 Subject: [PATCH 13/19] =?UTF-8?q?=F0=9F=94=A8=20moved=20encode=5Fdict=20ch?= =?UTF-8?q?eck=20allowed=5Fkeys=20to=20be=20first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/encoders.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index df6990089..851fdc3ee 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -369,12 +369,13 @@ def encode_dict( allowed_keys -= set(exclude) for key, value in obj.items(): - if sqlalchemy_safe and isinstance(key, str) and key.startswith("_sa"): + if key not in allowed_keys: continue if value is None and exclude_none: continue - if key not in allowed_keys: + if sqlalchemy_safe and isinstance(key, str) and key.startswith("_sa"): continue + encoded_key = encode_value( key, by_alias=by_alias, From 4bc25a414926fa7269dc1781f62c1ca4c0ca4aa7 Mon Sep 17 00:00:00 2001 From: Ben Brady Date: Thu, 28 Aug 2025 18:33:31 +0100 Subject: [PATCH 14/19] =?UTF-8?q?=F0=9F=94=A8=20added=20comments=20to=20ty?= =?UTF-8?q?pe=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Maxim Martynov --- fastapi/encoders.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 851fdc3ee..0d46c059b 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -236,9 +236,12 @@ def encode_value( sqlalchemy_safe: bool = True, ) -> Any: if custom_encoder: - if type(obj) in custom_encoder: - return custom_encoder[type(obj)](obj) + # fastpath for exact class match + obj_type = type(obj) + if obj_type in custom_encoder: + return custom_encoder[obj_type](obj) + # fallback to isinstance which uses MRO for encoder_type, encoder_instance in custom_encoder.items(): if isinstance(obj, encoder_type): return encoder_instance(obj) From c60ec7e6b2f8879251e64959fc56cb081310350b Mon Sep 17 00:00:00 2001 From: Ben Brady Date: Fri, 29 Aug 2025 12:21:52 +0100 Subject: [PATCH 15/19] =?UTF-8?q?=F0=9F=94=A8=20moved=20find=20encoder=20t?= =?UTF-8?q?o=20seperate=20function?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/encoders.py | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 0d46c059b..658eb3c61 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -236,15 +236,9 @@ def encode_value( sqlalchemy_safe: bool = True, ) -> Any: if custom_encoder: - # fastpath for exact class match - obj_type = type(obj) - if obj_type in custom_encoder: - return custom_encoder[obj_type](obj) - - # fallback to isinstance which uses MRO - for encoder_type, encoder_instance in custom_encoder.items(): - if isinstance(obj, encoder_type): - return encoder_instance(obj) + encoder = find_encoder(obj, custom_encoder) + if encoder: + return encoder(obj) if isinstance(obj, (str, int, float, NoneType)): # type: ignore[arg-type, misc] return obj @@ -323,12 +317,9 @@ def encode_value( 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(): - if isinstance(obj, classes_tuple): - return encoder(obj) + encoder = find_encoder(obj, ENCODERS_BY_TYPE) + if encoder: + return encoder(obj) try: obj_dict = dict(obj) @@ -399,3 +390,15 @@ def encode_dict( encoded_dict[encoded_key] = encoded_value return encoded_dict + + +def find_encoder(value: Any, encoders: Dict[Any, Callable[[Any], Any]]) -> Optional[Callable[[Any], Any]]: + # fastpath for exact class match + encoder = encoders.get(type(value)) + if encoder: + return encoder(value) + + # fallback to isinstance which uses MRO + for encoder_type, encoder in encoders.items(): + if isinstance(value, encoder_type): + return encoder From 303e1a5c38364306dd4a2d7758bfe9209f09bdb9 Mon Sep 17 00:00:00 2001 From: Ben Brady Date: Fri, 29 Aug 2025 12:47:11 +0100 Subject: [PATCH 16/19] =?UTF-8?q?=F0=9F=94=A8=20removed=20useless=20types?= =?UTF-8?q?=20in=20ENCODERS=5FBY=5FTYPE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/encoders.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 658eb3c61..d6b834141 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -64,9 +64,6 @@ ENCODERS_BY_TYPE: Dict[Type[Any], Callable[[Any], Any]] = { datetime.timedelta: lambda td: td.total_seconds(), Decimal: decimal_encoder, Enum: lambda o: o.value, - frozenset: list, - deque: list, - GeneratorType: list, IPv4Address: str, IPv4Interface: str, IPv4Network: str, @@ -79,7 +76,6 @@ ENCODERS_BY_TYPE: Dict[Type[Any], Callable[[Any], Any]] = { Pattern: lambda o: o.pattern, SecretBytes: str, SecretStr: str, - set: list, UUID: str, UndefinedType: lambda _: None, Url: str, From 273f6217e9b4daa340822786e235e19fca3c64c9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 29 Aug 2025 11:48:30 +0000 Subject: [PATCH 17/19] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20f?= =?UTF-8?q?ormat=20from=20pre-commit.com=20hooks?= 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 d6b834141..c5fce04c8 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -388,7 +388,9 @@ def encode_dict( return encoded_dict -def find_encoder(value: Any, encoders: Dict[Any, Callable[[Any], Any]]) -> Optional[Callable[[Any], Any]]: +def find_encoder( + value: Any, encoders: Dict[Any, Callable[[Any], Any]] +) -> Optional[Callable[[Any], Any]]: # fastpath for exact class match encoder = encoders.get(type(value)) if encoder: From 9e5d6cfd2b5cc31a0ce6e5828e837d9ab405bb7e Mon Sep 17 00:00:00 2001 From: Ben Brady Date: Fri, 29 Aug 2025 12:54:36 +0100 Subject: [PATCH 18/19] =?UTF-8?q?=F0=9F=94=A7=20fixed=20acciental=20call?= =?UTF-8?q?=20of=20encoder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/encoders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index c5fce04c8..903076ebc 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -394,7 +394,7 @@ def find_encoder( # fastpath for exact class match encoder = encoders.get(type(value)) if encoder: - return encoder(value) + return encoder # fallback to isinstance which uses MRO for encoder_type, encoder in encoders.items(): From 672aa5aee78a44ecc08f0cff590819c4b8eff148 Mon Sep 17 00:00:00 2001 From: Ben Brady Date: Fri, 29 Aug 2025 12:56:31 +0100 Subject: [PATCH 19/19] =?UTF-8?q?=F0=9F=94=A8=20added=20default=20return?= =?UTF-8?q?=20to=20find=5Fencoder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/encoders.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fastapi/encoders.py b/fastapi/encoders.py index 903076ebc..88f287084 100644 --- a/fastapi/encoders.py +++ b/fastapi/encoders.py @@ -400,3 +400,5 @@ def find_encoder( for encoder_type, encoder in encoders.items(): if isinstance(value, encoder_type): return encoder + + return None