Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 28 additions & 18 deletions ninja/signature/details.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -138,49 +139,58 @@ 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",
"cookie",
"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

Expand Down
5 changes: 4 additions & 1 deletion ninja/signature/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
55 changes: 54 additions & 1 deletion tests/test_signature_details.py
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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