Repository: vitalik/django-ninja
Pull request #1276 (2024-08-23 00:44:59)

Fixed coverage on Python>=3.11
`make test-cov` is not at 100% on Python>=3.11 when executed locally.
Nothing is wrong with the pipeline as it is still using Python 3.9 but it is a minor incovenience during development.

It is due to the fact that the handling of `typing.Any` becomes unnecessary with this change in cpython: python/cpython#88690

This PR resolves this situation by adding a quick UT that is not so useful for Python<3.11 but covers that specific `try` - `except` block.

We may (or may not) also add support for Python>=3.11 in the coverage pipeline, in this PR or another one.

c4ffein: I guess the change to `typing.Any` is introduced between 3.10 and 3.11, but better just ask for a first review anyway

Patch:
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index 2c1970e..bc758e3 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -1,2 +1,2 @@
-# Please support Ukraine volonteers
+# polar: django-ninja
 custom: ["https://www.buymeacoffee.com/djangoninja"]
diff --git a/docs/docs/guides/response/pagination.md b/docs/docs/guides/response/pagination.md
index fa3619a..68683d5 100644
--- a/docs/docs/guides/response/pagination.md
+++ b/docs/docs/guides/response/pagination.md
@@ -154,7 +154,7 @@ Standard **Django Ninja** pagination classes support async. If you wish to handl
 
 ### Output attribute
 
-By defult page items are placed to `'items'` attribute. To override this behaviour use `items_attribute`:
+By default page items are placed to `'items'` attribute. To override this behaviour use `items_attribute`:
 
 ```python hl_lines="4 8"
 class CustomPagination(PaginationBase):
diff --git a/ninja/openapi/schema.py b/ninja/openapi/schema.py
index 494e4e1..856c776 100644
--- a/ninja/openapi/schema.py
+++ b/ninja/openapi/schema.py
@@ -340,7 +340,8 @@ def flatten_properties(
         if len(prop_details["allOf"]) == 1 and "enum" in prop_details["allOf"][0]:
             # is_required = "default" not in prop_details
             yield prop_name, prop_details, prop_required
-        else:
+        else:  # pragma: no cover
+            # TODO: this code was for pydanitc 1.7+ ... <2.9 - check if this is still needed
             for item in prop_details["allOf"]:
                 yield from flatten_properties("", item, True, definitions)
 
diff --git a/ninja/pagination.py b/ninja/pagination.py
index 47ab70d..48ad1e2 100644
--- a/ninja/pagination.py
+++ b/ninja/pagination.py
@@ -282,8 +282,7 @@ def make_response_paginated(paginator: PaginationBase, op: Operation) -> None:
     try:
         new_name = f"Paged{item_schema.__name__}"
     except AttributeError:
-        new_name = f"Paged{str(item_schema).replace('.', '_')}"  # typing.Any case
-
+        new_name = f"Paged{str(item_schema).replace('.', '_')}"  # typing.Any case, only for Python < 3.11
     new_schema = type(
         new_name,
         (paginator.Output,),
diff --git a/ninja/signature/details.py b/ninja/signature/details.py
index a05903c..cb79cf1 100644
--- a/ninja/signature/details.py
+++ b/ninja/signature/details.py
@@ -72,6 +72,15 @@ class ViewSignature:
                 self.response_arg = name
                 continue
 
+            if (
+                arg.annotation is inspect.Parameter.empty
+                and isinstance(arg.default, type)
+                and issubclass(arg.default, pydantic.BaseModel)
+            ):
+                raise ConfigError(
+                    f"Looks like you are using `{name}={arg.default.__name__}` instead of `{name}: {arg.default.__name__}` (annotation)"
+                )
+
             func_param = self._get_param_type(name, arg)
             self.params.append(func_param)
 


Test patch:
diff --git a/tests/test_annotated.py b/tests/test_annotated.py
index 1802699..8d40ce0 100644
--- a/tests/test_annotated.py
+++ b/tests/test_annotated.py
@@ -1,6 +1,7 @@
 from typing import List
 
 from typing_extensions import Annotated
+from util import pydantic_ref_fix
 
 from ninja import Body, Cookie, Form, Header, NinjaAPI, Path, Query, Schema
 from ninja.testing import TestClient
@@ -192,10 +193,12 @@ def test_openapi_schema():
                 "requestBody": {
                     "content": {
                         "application/json": {
-                            "schema": {
-                                "allOf": [{"$ref": "#/components/schemas/Payload"}],
-                                "examples": [{"p": "test", "t": 42}],
-                            }
+                            "schema": pydantic_ref_fix(
+                                {
+                                    "$ref": "#/components/schemas/Payload",
+                                    "examples": [{"p": "test", "t": 42}],
+                                }
+                            )
                         }
                     },
                     "required": True,
diff --git a/tests/test_body.py b/tests/test_body.py
index 0f28846..bbb1d4a 100644
--- a/tests/test_body.py
+++ b/tests/test_body.py
@@ -1,6 +1,8 @@
+import pytest
 from pydantic import field_validator
 
 from ninja import Body, Form, NinjaAPI, Schema
+from ninja.errors import ConfigError
 from ninja.testing import TestClient
 
 api = NinjaAPI()
@@ -66,3 +68,17 @@ def test_body_validation_error():
             "ctx": {"error": "invalid email"},
         }
     ]
+
+
+def test_incorrect_annotation():
+    api = NinjaAPI()
+
+    class Some(Schema):
+        a: int
+
+    with pytest.raises(ConfigError):
+
+        @api.post("/some")
+        def some(request, payload=Some):
+            #  ................. ^------ invalid usage assigning class instead of annotation
+            return 42
diff --git a/tests/test_orm_schemas.py b/tests/test_orm_schemas.py
index b1f1164..d3fde8c 100644
--- a/tests/test_orm_schemas.py
+++ b/tests/test_orm_schemas.py
@@ -5,6 +5,7 @@ import pytest
 from django.contrib.postgres import fields as ps_fields
 from django.db import models
 from django.db.models import Manager
+from util import pydantic_ref_fix
 
 from ninja.errors import ConfigError
 from ninja.orm import create_schema
@@ -284,16 +285,18 @@ def test_relational():
     }
 
     SchemaClsDeep = create_schema(TestModel, name="TestSchemaDeep", depth=1)
-    # print(SchemaClsDeep.json_schema())
+    print(SchemaClsDeep.json_schema())
     assert SchemaClsDeep.json_schema() == {
         "type": "object",
         "properties": {
             "id": {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "ID"},
-            "onetoonefield": {
-                "title": "Onetoonefield",
-                "description": "",
-                "allOf": [{"$ref": "#/$defs/Related"}],
-            },
+            "onetoonefield": pydantic_ref_fix(
+                {
+                    "title": "Onetoonefield",
+                    "description": "",
+                    "$ref": "#/$defs/Related",
+                }
+            ),
             "foreignkey": {
                 "title": "Foreignkey",
                 "allOf": [{"$ref": "#/$defs/Related"}],
diff --git a/tests/test_pagination.py b/tests/test_pagination.py
index 4c9cdeb..35c8b40 100644
--- a/tests/test_pagination.py
+++ b/tests/test_pagination.py
@@ -1,12 +1,21 @@
 import importlib
+from sys import version_info
 from typing import Any, List
 
 import pytest
 from django.test import override_settings
+from pydantic.errors import PydanticSchemaGenerationError
 
 from ninja import NinjaAPI, Schema
 from ninja.errors import ConfigError
-from ninja.pagination import PageNumberPagination, PaginationBase, paginate
+from ninja.operation import Operation
+from ninja.pagination import (
+    LimitOffsetPagination,
+    PageNumberPagination,
+    PaginationBase,
+    make_response_paginated,
+    paginate,
+)
 from ninja.testing import TestClient
 
 api = NinjaAPI()
@@ -417,3 +426,17 @@ def test_config_error_NOT_SET():
         @paginate
         def invalid2(request):
             pass
+
+
+@pytest.mark.skipif(version_info < (3, 11), reason="Not needed at this Python version")
+def test_pagination_works_with_unnamed_classes():
+    """
+    This test lets you check that the typing.Any case handled in `ninja.pagination.make_response_paginated`
+    works for Python>=3.11, as a typing.Any does possess the __name__ atribute past that version
+    """
+    operation = Operation("/whatever", ["GET"], lambda: None, response=List[int])
+    operation.response_models[200].__annotations__["response"] = List[object()]
+    with pytest.raises(
+        PydanticSchemaGenerationError
+    ):  # It does fail after we passed the logic that we are testing
+        make_response_paginated(LimitOffsetPagination, operation)
diff --git a/tests/test_with_django/schema_fixtures/test-multi-body-file.json b/tests/test_with_django/schema_fixtures/test-multi-body-file.json
index 269b943..bb5deee 100644
--- a/tests/test_with_django/schema_fixtures/test-multi-body-file.json
+++ b/tests/test_with_django/schema_fixtures/test-multi-body-file.json
@@ -39,11 +39,7 @@
               "data": {
                 "title": "Data4 Title",
                 "description": "Data4 Desc",
-                "allOf": [
-                  {
-                    "$ref": "#/components/schemas/TestData4"
-                  }
-                ]
+                "$ref": "#/components/schemas/TestData4"
               },
               "nested-data": {
                 "$ref": "#/components/schemas/TestData"
diff --git a/tests/test_with_django/schema_fixtures/test-multi-body-form-file.json b/tests/test_with_django/schema_fixtures/test-multi-body-form-file.json
index 43430b7..9a610c6 100644
--- a/tests/test_with_django/schema_fixtures/test-multi-body-form-file.json
+++ b/tests/test_with_django/schema_fixtures/test-multi-body-form-file.json
@@ -68,11 +68,7 @@
               "data": {
                 "title": "Data4 Title",
                 "description": "Data4 Desc",
-                "allOf": [
-                  {
-                    "$ref": "#/components/schemas/TestData4"
-                  }
-                ]
+                "$ref": "#/components/schemas/TestData4"
               }
             },
             "required": [
diff --git a/tests/test_with_django/schema_fixtures/test-multi-body-form.json b/tests/test_with_django/schema_fixtures/test-multi-body-form.json
index 07d563e..7ec66f4 100644
--- a/tests/test_with_django/schema_fixtures/test-multi-body-form.json
+++ b/tests/test_with_django/schema_fixtures/test-multi-body-form.json
@@ -63,11 +63,7 @@
               "data": {
                 "title": "Data4 Title",
                 "description": "Data4 Desc",
-                "allOf": [
-                  {
-                    "$ref": "#/components/schemas/TestData4"
-                  }
-                ]
+                "$ref": "#/components/schemas/TestData4"
               }
             },
             "required": [
diff --git a/tests/test_with_django/schema_fixtures/test-multi-body.json b/tests/test_with_django/schema_fixtures/test-multi-body.json
index d3a1435..1b1d40d 100644
--- a/tests/test_with_django/schema_fixtures/test-multi-body.json
+++ b/tests/test_with_django/schema_fixtures/test-multi-body.json
@@ -34,11 +34,7 @@
               "data": {
                 "title": "Data4 Title",
                 "description": "Data4 Desc",
-                "allOf": [
-                  {
-                    "$ref": "#/components/schemas/TestData4"
-                  }
-                ]
+                "$ref": "#/components/schemas/TestData4"
               },
               "nested-data": {
                 "$ref": "#/components/schemas/TestData"
diff --git a/tests/test_with_django/test_multi_param_parsing.py b/tests/test_with_django/test_multi_param_parsing.py
index 5b3fd1d..c47eba1 100644
--- a/tests/test_with_django/test_multi_param_parsing.py
+++ b/tests/test_with_django/test_multi_param_parsing.py
@@ -1,4 +1,5 @@
 import json
+import re
 from pathlib import Path
 
 import pytest
@@ -105,7 +106,13 @@ def test_validate_test_data():
                 json.dump(schema["paths"][path], f, indent=2)
         with Path(filename).open() as f:
             data = json.load(f)
-            assert json.loads(json.dumps(schema["paths"][path])) == data
+            path_schema = json.dumps(schema["paths"][path])
+            if "allOf" in path_schema:
+                # special case for pydantic 1.7 ... 2.9
+                path_schema = re.sub(
+                    r'"allOf": \[\{"\$ref": "(.*?)"\}\]', r'"$ref": "\1"', path_schema
+                )
+            assert json.loads(path_schema) == data
 
 
 @pytest.mark.parametrize("path, client_args", tuple(test_client_args.items()))
diff --git a/tests/util.py b/tests/util.py
new file mode 100644
index 0000000..12e046b
--- /dev/null
+++ b/tests/util.py
@@ -0,0 +1,13 @@
+import pydantic
+
+
+def pydantic_ref_fix(data: dict):
+    "In pydantic 1.7 $ref was changed to allOf: [{'$ref': ...}] but in 2.9 it was changed back"
+    v = tuple(map(int, pydantic.version.version_short().split(".")))
+    if v < (1, 7) or v >= (2, 9):
+        return data
+
+    result = data.copy()
+    if "$ref" in data:
+        result["allOf"] = [{"$ref": result.pop("$ref")}]
+    return result