diff --git a/docs/body-adapter-performance.md b/docs/body-adapter-performance.md new file mode 100644 index 0000000..83aee55 --- /dev/null +++ b/docs/body-adapter-performance.md @@ -0,0 +1,24 @@ +# Static body adapter reuse + +The default Pydantic decorator path prepares one body `TypeAdapter` during +decoration. The adapter is private to that decorator instance and reused only +for the identical model object. Direct adapter calls and explicitly supplied +custom adapters keep their existing `parse_body(req, model)` contract. Query +validation continues to use `model_validate`; no global cache is introduced. +Failed preparation retains the prior request-time error behavior. Dynamic +model rebuilds after decoration are not a static contract and require redecorating. + +Reproduce with `hatch run python scripts/benchmark_body_adapter.py` after environment +setup. Five repeats of 10,000 validations compare construction plus validation +against a prebuilt adapter, reporting the minimum per-call time. Local Python +3.11.15 / Pydantic 2.14.1 observations: + +| Annotation | Construct each call | Reuse | Saved per validation | +| --- | ---: | ---: | ---: | +| Small model | 5.638 μs | 0.728 μs | 4.910 μs | +| Nested model | 6.223 μs | 1.225 μs | 4.998 μs | +| Small-or-nested union | 75.183 μs | 1.000 μs | 74.183 μs | + +These are adapter-only microbenchmarks, not HTTP latency promises. The cost moves +to decoration and retains one adapter/model reference per default decorator. +Custom adapters receive no new required keyword arguments or mutable shared cache. diff --git a/scripts/benchmark_body_adapter.py b/scripts/benchmark_body_adapter.py new file mode 100644 index 0000000..8493499 --- /dev/null +++ b/scripts/benchmark_body_adapter.py @@ -0,0 +1,24 @@ +from timeit import repeat +from typing import Any + +from pydantic import BaseModel, TypeAdapter + + +class Small(BaseModel): + value: int + + +class Nested(BaseModel): + items: list[Small] + + +cases: tuple[tuple[Any, Any], ...] = ( + (Small, {"value": 1}), + (Nested, {"items": [{"value": 1}]}), + (Small | Nested, {"value": 1}), +) +for model, data in cases: + adapter = TypeAdapter(model) + fresh = min(repeat(lambda: TypeAdapter(model).validate_python(data), number=10000, repeat=5)) + cached = min(repeat(lambda: adapter.validate_python(data), number=10000, repeat=5)) + print(f"{model}: fresh={fresh * 100:.3f} us cached={cached * 100:.3f} us") diff --git a/src/azure_functions_validation/adapter.py b/src/azure_functions_validation/adapter.py index 3899945..c0258dd 100644 --- a/src/azure_functions_validation/adapter.py +++ b/src/azure_functions_validation/adapter.py @@ -332,6 +332,7 @@ def __init__(self, *, legacy_loc: bool = False) -> None: hatch and will be removed in a future release. """ self._legacy_loc = legacy_loc + self._prepared_body: tuple[Any, TypeAdapter[Any]] | None = None @staticmethod def _to_adapter_error( @@ -412,7 +413,11 @@ def parse_body(self, req: HttpRequest, model: Any) -> Any: # Validate with Pydantic try: - return TypeAdapter(model).validate_python(data) + prepared = self._prepared_body + adapter = ( + prepared[1] if prepared is not None and prepared[0] is model else TypeAdapter(model) + ) + return adapter.validate_python(data) except PydanticValidationError as exc: raise self._source_error(exc, "body") from exc diff --git a/src/azure_functions_validation/decorator.py b/src/azure_functions_validation/decorator.py index 5de4211..da94e6b 100644 --- a/src/azure_functions_validation/decorator.py +++ b/src/azure_functions_validation/decorator.py @@ -112,6 +112,7 @@ def validate_http( body = request_model # Use default adapter if none provided + default_adapter = adapter is None if adapter is None: adapter = PydanticAdapter(legacy_loc=legacy_loc) @@ -159,6 +160,12 @@ def decorator(func: Callable[..., Any]) -> Callable[..., Any]: is_async = inspect.iscoroutinefunction(func) + if default_adapter and isinstance(adapter, PydanticAdapter) and body is not None: + try: + adapter._prepared_body = (body, TypeAdapter(body)) + except Exception: + adapter._prepared_body = None + func_sig = inspect.signature(func) func_params = func_sig.parameters diff --git a/tests/test_body_adapter_cache.py b/tests/test_body_adapter_cache.py new file mode 100644 index 0000000..568a041 --- /dev/null +++ b/tests/test_body_adapter_cache.py @@ -0,0 +1,39 @@ +import json +from unittest.mock import patch + +from azure.functions import HttpRequest +from pydantic import BaseModel + +from azure_functions_validation import PydanticAdapter, validate_http + + +class Body(BaseModel): + value: int + + +def test_static_body_adapter_is_prepared_before_requests() -> None: + @validate_http(body=Body) + def handler(req: HttpRequest, body: Body) -> dict[str, int]: + return {"value": body.value} + + request = HttpRequest("POST", "https://example.test", body=b'{"value":1}') + with patch( + "azure_functions_validation.adapter.TypeAdapter", side_effect=AssertionError("rebuilt") + ): + assert handler(request).status_code == 200 + assert handler(request).status_code == 200 + + +def test_custom_adapter_parse_signature_and_shared_models_remain_independent() -> None: + class Custom(PydanticAdapter): + def parse_body(self, req: HttpRequest, model: type[BaseModel]) -> BaseModel: + return model.model_validate({"value": 9}) + + adapter = Custom() + + @validate_http(body=Body, query=Body, adapter=adapter) + def handler(req: HttpRequest, body: Body, query: Body) -> dict[str, int]: + return {"value": body.value + query.value} + + request = HttpRequest("POST", "https://example.test", params={"value": "2"}, body=b"{}") + assert json.loads(handler(request).get_body()) == {"value": 11}