diff --git a/ninja/signature/details.py b/ninja/signature/details.py index ba2d37b51..5177fd8aa 100644 --- a/ninja/signature/details.py +++ b/ninja/signature/details.py @@ -6,6 +6,7 @@ import pydantic from django.http import HttpResponse +from pydantic import create_model from pydantic.fields import FieldInfo from pydantic_core import PydanticUndefined from typing_extensions import Annotated, get_args, get_origin @@ -138,14 +139,29 @@ def _create_models(self) -> TModels: result = [] for param_cls, args in params_by_source_cls.items(): cls_name: str = param_cls.__name__ + "Params" - attrs = {i.name: i.source for i in args} - attrs["__ninja_param_source__"] = param_cls._param_source() - attrs["__ninja_flatten_map_reverse__"] = {} - if attrs["__ninja_param_source__"] == "file": + # Build field definitions for pydantic.create_model + # Format: {field_name: (annotation, FieldInfo)} + field_definitions = {} + for arg in args: + field_definitions[arg.name] = (arg.annotation, arg.source) + + base_cls = param_cls._model + model_cls = create_model( # type: ignore[call-overload] + cls_name, + __base__=base_cls, + __module__=self.view_func.__module__, + **field_definitions, + ) + + # Attach ninja-specific attributes to the dynamically created model + model_cls.__ninja_param_source__ = param_cls._param_source() + model_cls.__ninja_flatten_map_reverse__ = {} + + if model_cls.__ninja_param_source__ == "file": pass - elif attrs["__ninja_param_source__"] in { + elif model_cls.__ninja_param_source__ in { "form", "query", "header", @@ -153,34 +169,28 @@ def _create_models(self) -> TModels: "path", }: flatten_map = self._args_flatten_map(args) - attrs["__ninja_flatten_map__"] = flatten_map - attrs["__ninja_flatten_map_reverse__"] = { + model_cls.__ninja_flatten_map__ = flatten_map + model_cls.__ninja_flatten_map_reverse__ = { v: (k,) for k, v in flatten_map.items() } else: - assert attrs["__ninja_param_source__"] == "body" + assert model_cls.__ninja_param_source__ == "body" if is_multipart_response_with_body: - attrs["__ninja_body_params__"] = { + model_cls.__ninja_body_params__ = { i.alias: i.annotation for i in args } else: # ::TODO:: this is still sus. build some test cases - attrs["__read_from_single_attr__"] = ( + model_cls.__read_from_single_attr__ = ( args[0].name if len(args) == 1 else None ) - # adding annotations - attrs["__annotations__"] = {i.name: i.annotation for i in args} - # collection fields: - attrs["__ninja_collection_fields__"] = detect_collection_fields( - args, attrs.get("__ninja_flatten_map__", {}) + model_cls.__ninja_collection_fields__ = detect_collection_fields( + args, model_cls.__dict__.get("__ninja_flatten_map__", {}) ) - base_cls = param_cls._model - model_cls = type(cls_name, (base_cls,), attrs) - # TODO: https://pydantic-docs.helpmanual.io/usage/models/#dynamic-model-creation - check if anything special in create_model method that I did not use result.append(model_cls) return result diff --git a/ninja/signature/utils.py b/ninja/signature/utils.py index c3993d83e..69d1f6935 100644 --- a/ninja/signature/utils.py +++ b/ninja/signature/utils.py @@ -21,7 +21,10 @@ def get_typed_signature(call: Callable[..., Any]) -> inspect.Signature: "Finds call signature and resolves all forwardrefs" signature = inspect.signature(call) - globalns = getattr(call, "__globals__", {}) + unwrapped = call + while hasattr(unwrapped, "__wrapped__"): + unwrapped = unwrapped.__wrapped__ + globalns = getattr(unwrapped, "__globals__", {}) typed_params = [ inspect.Parameter( name=param.name, diff --git a/tests/test_signature_details.py b/tests/test_signature_details.py index 38168519f..670844c66 100644 --- a/tests/test_signature_details.py +++ b/tests/test_signature_details.py @@ -1,9 +1,13 @@ +from __future__ import annotations + import typing +from functools import wraps from sys import version_info import pytest -from ninja.signature.details import is_collection_type +from ninja import NinjaAPI, Schema +from ninja.signature.details import ViewSignature, is_collection_type @pytest.mark.parametrize( @@ -49,3 +53,52 @@ ) def test_is_collection_type_returns(annotation: typing.Any, expected: bool): assert is_collection_type(annotation) is expected + + +class PayloadSchema(Schema): + name: str + value: int + + +def test_parameter_model_has_view_module(): + """Parameter models must inherit __module__ from view function.""" + api = NinjaAPI() + + @api.post("/test") + def view(request, payload: PayloadSchema): + return {} + + signature = ViewSignature("/test", view) + body_model = next(m for m in signature.models if m.__ninja_param_source__ == "body") + + assert body_model.__module__ == view.__module__ + assert body_model.__module__ == __name__ + + +def test_decorated_function_resolves_string_annotations(): + """Decorated functions with string annotations must resolve correctly.""" + api = NinjaAPI() + + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + @api.post("/test") + @decorator + def view(request, payload: PayloadSchema): + return {} + + signature = ViewSignature("/test", view) + + body_model = next( + (m for m in signature.models if m.__ninja_param_source__ == "body"), None + ) + query_model = next( + (m for m in signature.models if m.__ninja_param_source__ == "query"), None + ) + + assert body_model is not None + assert query_model is None