Browse Source

Merge c9b869ed38 into eb75fd078e

pull/15965/merge
Rajat Jain 4 days ago
committed by GitHub
parent
commit
1b17bb7071
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 60
      benchmark_encoder.py
  2. 18
      fastapi/encoders.py
  3. 51
      tests/test_encoder_performance.py
  4. 62
      tests/test_jsonable_encoder.py

60
benchmark_encoder.py

@ -0,0 +1,60 @@
import time
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel
class SubModel(BaseModel):
name: str
value: int = 42
class MainModel(BaseModel):
id: int
title: str
sub: SubModel
items: list[SubModel]
maybe: str | None = None
def run_benchmark():
sub = SubModel(name="test")
model = MainModel(
id=1,
title="hello",
sub=sub,
items=[sub] * 50, # 50 items to have a decent dictionary size
)
iterations = 20000
print(f"Benchmarking jsonable_encoder over {iterations} iterations...")
# 1. Optimized Path (Direct return)
# Warmup
for _ in range(100):
jsonable_encoder(model)
start_time = time.perf_counter()
for _ in range(iterations):
jsonable_encoder(model)
optimized_time = time.perf_counter() - start_time
# 2. Original Path (Double serialization via model_dump + recursive dict encoding)
# Warmup
for _ in range(100):
jsonable_encoder(model.model_dump(mode="json"))
start_time = time.perf_counter()
for _ in range(iterations):
# We simulate the exact old logic: model_dump(mode="json") followed by recursive jsonable_encoder
obj_dict = model.model_dump(mode="json")
jsonable_encoder(obj_dict)
original_time = time.perf_counter() - start_time
print(f"Original Code Path: {original_time:.4f} seconds")
print(f"Optimized Code Path: {optimized_time:.4f} seconds")
print(f"Speedup: {original_time / optimized_time:.2f}x")
if __name__ == "__main__":
run_benchmark()

18
fastapi/encoders.py

@ -241,6 +241,24 @@ def jsonable_encoder(
if exclude is not None and not isinstance(exclude, (set, dict)):
exclude = set(exclude) # type: ignore[assignment] # ty: ignore[invalid-assignment]
if isinstance(obj, BaseModel):
if not exclude_none and not exclude_defaults:
obj_dict = obj.model_dump(
mode="json",
include=include,
exclude=exclude,
by_alias=by_alias,
exclude_unset=exclude_unset,
exclude_none=exclude_none,
exclude_defaults=exclude_defaults,
)
if not sqlalchemy_safe:
return obj_dict
return {
k: v
for k, v in obj_dict.items()
if not (isinstance(k, str) and k.startswith("_sa"))
}
# Fallback to recursive call for recursive exclude_none/exclude_defaults cleanup
obj_dict = obj.model_dump(
mode="json",
include=include,

51
tests/test_encoder_performance.py

@ -0,0 +1,51 @@
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel
class SubModel(BaseModel):
name: str
value: int = 42
class MainModel(BaseModel):
id: int
title: str
sub: SubModel
items: list[SubModel]
maybe: str | None = None
_sa_instance_state: str = "should-be-removed"
def test_basemodel_serialization_correctness():
sub = SubModel(name="test")
model = MainModel(
id=1,
title="hello",
sub=sub,
items=[sub, SubModel(name="another", value=10)],
)
# 1. Standard serialization
encoded = jsonable_encoder(model)
assert encoded == {
"id": 1,
"title": "hello",
"sub": {"name": "test", "value": 42},
"items": [
{"name": "test", "value": 42},
{"name": "another", "value": 10},
],
"maybe": None,
}
# 2. Exclude none
encoded_exclude_none = jsonable_encoder(model, exclude_none=True)
assert "maybe" not in encoded_exclude_none
# 3. Include and Exclude parameter filtering
encoded_filtered = jsonable_encoder(model, include={"id", "title"})
assert encoded_filtered == {"id": 1, "title": "hello"}
encoded_excluded = jsonable_encoder(model, exclude={"sub", "items"})
assert "sub" not in encoded_excluded
assert "items" not in encoded_excluded

62
tests/test_jsonable_encoder.py

@ -329,3 +329,65 @@ def test_encode_color(module_path):
data = {"color": Color("blue")}
assert jsonable_encoder(data) == {"color": "blue"}
def test_jsonable_encoder_sqlalchemy_safe_base_model():
class Model(BaseModel):
sa_key: str = Field(alias="_sa_key")
normal_key: str
model = Model(_sa_key="foo", normal_key="bar")
# Test case 1: sqlalchemy_safe=True (default) and no exclude_none/exclude_defaults
assert jsonable_encoder(model, by_alias=True) == {"normal_key": "bar"}
# Test case 2: sqlalchemy_safe=False and no exclude_none/exclude_defaults
# This hits: "if not sqlalchemy_safe: return obj_dict"
assert jsonable_encoder(model, by_alias=True, sqlalchemy_safe=False) == {
"_sa_key": "foo",
"normal_key": "bar",
}
# Test case 3: sqlalchemy_safe=True (default) and exclude_none=True
assert jsonable_encoder(model, by_alias=True, exclude_none=True) == {
"normal_key": "bar"
}
# Test case 4: sqlalchemy_safe=False and exclude_none=True
assert jsonable_encoder(
model, by_alias=True, exclude_none=True, sqlalchemy_safe=False
) == {
"_sa_key": "foo",
"normal_key": "bar",
}
def test_jsonable_encoder_sqlalchemy_safe_dict():
data = {"_sa_key": "foo", "normal_key": "bar"}
# Test case 5: dict with sqlalchemy_safe=True (default)
assert jsonable_encoder(data) == {"normal_key": "bar"}
# Test case 6: dict with sqlalchemy_safe=False
assert jsonable_encoder(data, sqlalchemy_safe=False) == {
"_sa_key": "foo",
"normal_key": "bar",
}
def test_jsonable_encoder_sqlalchemy_safe_dataclass():
@dataclass
class DataclassItem:
normal_key: str
_sa_key: str
item = DataclassItem(normal_key="bar", _sa_key="foo")
# Test case 7: dataclass with sqlalchemy_safe=True (default)
assert jsonable_encoder(item) == {"normal_key": "bar"}
# Test case 8: dataclass with sqlalchemy_safe=False
assert jsonable_encoder(item, sqlalchemy_safe=False) == {
"_sa_key": "foo",
"normal_key": "bar",
}

Loading…
Cancel
Save