From 9cb132c5a458735ebd70640fdb362e1ad0d7dd0e Mon Sep 17 00:00:00 2001 From: Vitaliy Kucheryaviy Date: Sun, 1 Oct 2023 20:44:01 +0300 Subject: [PATCH] Shorter Params Syntax --- .github/workflows/test_full.yml | 3 +- ninja/__init__.py | 28 ++++- ninja/openapi/schema.py | 2 +- ninja/operation.py | 2 +- ninja/params.py | 105 ---------------- ninja/params/__init__.py | 113 ++++++++++++++++++ .../functions.py} | 22 ++-- ninja/{params_models.py => params/models.py} | 101 +++++++++++++++- ninja/signature/details.py | 31 +++-- tests/mypy_test.py | 36 ++++++ 10 files changed, 309 insertions(+), 134 deletions(-) delete mode 100644 ninja/params.py create mode 100644 ninja/params/__init__.py rename ninja/{params_functions.py => params/functions.py} (96%) rename ninja/{params_models.py => params/models.py} (68%) create mode 100644 tests/mypy_test.py diff --git a/.github/workflows/test_full.yml b/.github/workflows/test_full.yml index 7785898d4..131efed08 100644 --- a/.github/workflows/test_full.yml +++ b/.github/workflows/test_full.yml @@ -59,5 +59,4 @@ jobs: - name: Ruff run: ruff ninja tests - name: mypy - run: mypy ninja - + run: mypy ninja tests/mypy_test.py diff --git a/ninja/__init__.py b/ninja/__init__.py index 89ce29947..94f139a21 100644 --- a/ninja/__init__.py +++ b/ninja/__init__.py @@ -1,6 +1,6 @@ """Django Ninja - Fast Django REST framework""" -__version__ = "1.0b1" +__version__ = "1.0b2" from pydantic import Field @@ -10,7 +10,23 @@ from ninja.main import NinjaAPI from ninja.openapi.docs import Redoc, Swagger from ninja.orm import ModelSchema -from ninja.params_functions import Body, Cookie, File, Form, Header, Path, Query +from ninja.params import ( + Body, + BodyEx, + Cookie, + CookieEx, + File, + FileEx, + Form, + FormEx, + Header, + HeaderEx, + P, + Path, + PathEx, + Query, + QueryEx, +) from ninja.router import Router from ninja.schema import Schema @@ -25,7 +41,15 @@ "Header", "Path", "Query", + "BodyEx", + "CookieEx", + "FileEx", + "FormEx", + "HeaderEx", + "PathEx", + "QueryEx", "Router", + "P", "Schema", "ModelSchema", "FilterSchema", diff --git a/ninja/openapi/schema.py b/ninja/openapi/schema.py index 311089cba..049cdccf6 100644 --- a/ninja/openapi/schema.py +++ b/ninja/openapi/schema.py @@ -6,7 +6,7 @@ from ninja.constants import NOT_SET from ninja.operation import Operation -from ninja.params_models import TModel, TModels +from ninja.params.models import TModel, TModels from ninja.schema import NinjaGenerateJsonSchema from ninja.types import DictStrAny from ninja.utils import normalize_path diff --git a/ninja/operation.py b/ninja/operation.py index 55321b6e0..5ac29913e 100644 --- a/ninja/operation.py +++ b/ninja/operation.py @@ -20,7 +20,7 @@ from ninja.compatibility.util import async_to_sync from ninja.constants import NOT_SET from ninja.errors import AuthenticationError, ConfigError, ValidationError -from ninja.params_models import TModels +from ninja.params.models import TModels from ninja.schema import Schema from ninja.signature import ViewSignature, is_async from ninja.types import DictStrAny diff --git a/ninja/params.py b/ninja/params.py deleted file mode 100644 index 21007a349..000000000 --- a/ninja/params.py +++ /dev/null @@ -1,105 +0,0 @@ -from typing import Any, Dict, Optional - -from pydantic.fields import FieldInfo - -from ninja import params_models - -__all__ = ["Param", "Path", "Query", "Header", "Cookie", "Body", "Form", "File"] - - -class Param(FieldInfo): - def __init__( - self, - default: Any, - *, - alias: str = None, - title: str = None, - description: str = None, - gt: float = None, - ge: float = None, - lt: float = None, - le: float = None, - min_length: int = None, - max_length: int = None, - regex: str = None, - example: Any = None, - examples: Optional[Dict[str, Any]] = None, - deprecated: bool = None, - include_in_schema: bool = True, - # param_name: str = None, - # param_type: Any = None, - **extra: Any, - ): - self.deprecated = deprecated - # self.param_name: str = None - # self.param_type: Any = None - self.model_field: Optional[FieldInfo] = None - json_schema_extra = {} - if example: - json_schema_extra["example"] = example - if examples: - json_schema_extra["examples"] = examples - if deprecated: - json_schema_extra["deprecated"] = deprecated - if not include_in_schema: - json_schema_extra["include_in_schema"] = include_in_schema - if alias and not extra.get("validation_alias"): - extra["validation_alias"] = alias - if alias and not extra.get("serialization_alias"): - extra["serialization_alias"] = alias - super().__init__( - default=default, - alias=alias, - title=title, - description=description, - gt=gt, - ge=ge, - lt=lt, - le=le, - min_length=min_length, - max_length=max_length, - regex=regex, - json_schema_extra=json_schema_extra, - **extra, - ) - - @classmethod - def _param_source(cls) -> str: - "Openapi param.in value or body type" - return cls.__name__.lower() - - -class Path(Param): - _model = params_models.PathModel - - -class Query(Param): - _model = params_models.QueryModel - - -class Header(Param): - _model = params_models.HeaderModel - - -class Cookie(Param): - _model = params_models.CookieModel - - -class Body(Param): - _model = params_models.BodyModel - - -class Form(Param): - _model = params_models.FormModel - - -class File(Param): - _model = params_models.FileModel - - -class _MultiPartBody(Param): - _model = params_models._MultiPartBodyModel - - @classmethod - def _param_source(cls) -> str: - return "body" diff --git a/ninja/params/__init__.py b/ninja/params/__init__.py new file mode 100644 index 000000000..920b43632 --- /dev/null +++ b/ninja/params/__init__.py @@ -0,0 +1,113 @@ +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, TypeVar + +from typing_extensions import Annotated + +from ninja.params import functions as param_functions + +__all__ = [ + "Body", + "Cookie", + "File", + "Form", + "Header", + "Path", + "Query", + "BodyEx", + "CookieEx", + "FileEx", + "FormEx", + "HeaderEx", + "PathEx", + "QueryEx", + "Router", + "P", +] + + +class ParamShortcut: + def __init__(self, base_func: Callable) -> None: + self._base_func = base_func + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + return self._base_func(*args, **kwargs) + + def __getitem__(self, args: Any) -> Any: + if isinstance(args, tuple): + return Annotated[args[0], self._base_func(**args[1])] + return Annotated[args, self._base_func()] + + +if TYPE_CHECKING: # pragma: nocover + # mypy cheats + T = TypeVar("T") + Body = Annotated[T, param_functions.Body()] + Cookie = Annotated[T, param_functions.Cookie()] + File = Annotated[T, param_functions.File()] + Form = Annotated[T, param_functions.Form()] + Header = Annotated[T, param_functions.Header()] + Path = Annotated[T, param_functions.Path()] + Query = Annotated[T, param_functions.Query()] + # mypy does not like to extend already annotated params + # with extra annotation (so need to cheat with these XXX-Ex types): + from typing_extensions import Annotated as BodyEx + from typing_extensions import Annotated as CookieEx + from typing_extensions import Annotated as FileEx + from typing_extensions import Annotated as FormEx + from typing_extensions import Annotated as HeaderEx + from typing_extensions import Annotated as PathEx + from typing_extensions import Annotated as QueryEx +else: + Body = ParamShortcut(param_functions.Body) + Cookie = ParamShortcut(param_functions.Cookie) + File = ParamShortcut(param_functions.File) + Form = ParamShortcut(param_functions.Form) + Header = ParamShortcut(param_functions.Header) + Path = ParamShortcut(param_functions.Path) + Query = ParamShortcut(param_functions.Query) + # mypy does not like to extend already annotated params + # with extra annotation (so need to cheat with these XXX-Ex types): + BodyEx = Body + CookieEx = Cookie + FileEx = File + FormEx = Form + HeaderEx = Header + PathEx = Path + QueryEx = Query + + +def P( + *, + alias: Optional[str] = None, + title: Optional[str] = None, + description: Optional[str] = None, + gt: Optional[float] = None, + ge: Optional[float] = None, + lt: Optional[float] = None, + le: Optional[float] = None, + min_length: Optional[int] = None, + max_length: Optional[int] = None, + regex: Optional[str] = None, + example: Any = None, + examples: Optional[Dict[str, Any]] = None, + deprecated: Optional[bool] = None, + include_in_schema: bool = True, + **extra: Any, +) -> Dict[str, Any]: + "Arguments for BodyEx, QueryEx, etc." + return dict( + alias=alias, + title=title, + description=description, + gt=gt, + ge=ge, + lt=lt, + le=le, + min_length=min_length, + max_length=max_length, + regex=regex, + example=example, + examples=examples, + deprecated=deprecated, + include_in_schema=include_in_schema, + **extra, + ) diff --git a/ninja/params_functions.py b/ninja/params/functions.py similarity index 96% rename from ninja/params_functions.py rename to ninja/params/functions.py index aebe03122..899f29d90 100644 --- a/ninja/params_functions.py +++ b/ninja/params/functions.py @@ -1,11 +1,11 @@ # Yeah, this is a bit strange # but the whole point of this module is to make mypy and typehints happy -# what it basically does makes function XXX that create instance of params.XXX +# what it basically does makes function XXX that create instance of models.XXX # and annotates function with result = Any # idea from https://github.com/tiangolo/fastapi/blob/master/fastapi/param_functions.py from typing import Any, Dict, Optional -from ninja import params +from ninja.params import models def Path( # noqa: N802 @@ -27,8 +27,8 @@ def Path( # noqa: N802 include_in_schema: bool = True, **extra: Any, ) -> Any: - return params.Path( - default=default, + return models.Path( + default, alias=alias, title=title, description=description, @@ -66,8 +66,8 @@ def Query( # noqa: N802 include_in_schema: bool = True, **extra: Any, ) -> Any: - return params.Query( - default=default, + return models.Query( + default, alias=alias, title=title, description=description, @@ -105,7 +105,7 @@ def Header( # noqa: N802 include_in_schema: bool = True, **extra: Any, ) -> Any: - return params.Header( + return models.Header( default, alias=alias, title=title, @@ -144,7 +144,7 @@ def Cookie( # noqa: N802 include_in_schema: bool = True, **extra: Any, ) -> Any: - return params.Cookie( + return models.Cookie( default, alias=alias, title=title, @@ -183,7 +183,7 @@ def Body( # noqa: N802 include_in_schema: bool = True, **extra: Any, ) -> Any: - return params.Body( + return models.Body( default, alias=alias, title=title, @@ -222,7 +222,7 @@ def Form( # noqa: N802 include_in_schema: bool = True, **extra: Any, ) -> Any: - return params.Form( + return models.Form( default, alias=alias, title=title, @@ -261,7 +261,7 @@ def File( # noqa: N802 include_in_schema: bool = True, **extra: Any, ) -> Any: - return params.File( + return models.File( default, alias=alias, title=title, diff --git a/ninja/params_models.py b/ninja/params/models.py similarity index 68% rename from ninja/params_models.py rename to ninja/params/models.py index dc2a95bec..e49735114 100644 --- a/ninja/params_models.py +++ b/ninja/params/models.py @@ -1,10 +1,11 @@ from abc import ABC, abstractmethod from collections import defaultdict -from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Type, TypeVar +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Type, TypeVar from django.conf import settings from django.http import HttpRequest from pydantic import BaseModel +from pydantic.fields import FieldInfo from ninja.compatibility import get_headers from ninja.errors import HttpError @@ -184,3 +185,101 @@ def get_request_data( req.body = data.encode() results[name] = get_request_data(req, api, path_params) return results + + +class Param(FieldInfo): + def __init__( + self, + default: Any, + *, + alias: str = None, + title: str = None, + description: str = None, + gt: float = None, + ge: float = None, + lt: float = None, + le: float = None, + min_length: int = None, + max_length: int = None, + regex: str = None, + example: Any = None, + examples: Optional[Dict[str, Any]] = None, + deprecated: bool = None, + include_in_schema: bool = True, + # param_name: str = None, + # param_type: Any = None, + **extra: Any, + ): + self.deprecated = deprecated + # self.param_name: str = None + # self.param_type: Any = None + self.model_field: Optional[FieldInfo] = None + json_schema_extra = {} + if example: + json_schema_extra["example"] = example + if examples: + json_schema_extra["examples"] = examples + if deprecated: + json_schema_extra["deprecated"] = deprecated + if not include_in_schema: + json_schema_extra["include_in_schema"] = include_in_schema + if alias and not extra.get("validation_alias"): + extra["validation_alias"] = alias + if alias and not extra.get("serialization_alias"): + extra["serialization_alias"] = alias + super().__init__( + default=default, + alias=alias, + title=title, + description=description, + gt=gt, + ge=ge, + lt=lt, + le=le, + min_length=min_length, + max_length=max_length, + regex=regex, + json_schema_extra=json_schema_extra, + **extra, + ) + + @classmethod + def _param_source(cls) -> str: + "Openapi param.in value or body type" + return cls.__name__.lower() + + +class Path(Param): + _model = PathModel + + +class Query(Param): + _model = QueryModel + + +class Header(Param): + _model = HeaderModel + + +class Cookie(Param): + _model = CookieModel + + +class Body(Param): + _model = BodyModel + + +class Form(Param): + _model = FormModel + + +class File(Param): + _model = FileModel + + +class _MultiPartBody(Param): + _model = _MultiPartBodyModel + + @classmethod + def _param_source(cls) -> str: + return "body" diff --git a/ninja/signature/details.py b/ninja/signature/details.py index f767bc57d..067614c88 100644 --- a/ninja/signature/details.py +++ b/ninja/signature/details.py @@ -9,11 +9,20 @@ from pydantic_core import PydanticUndefined from typing_extensions import Annotated, get_args, get_origin # type: ignore -from ninja import UploadedFile, params +from ninja import UploadedFile from ninja.compatibility.util import UNION_TYPES from ninja.errors import ConfigError -from ninja.params import Body, File, Form, _MultiPartBody -from ninja.params_models import TModel, TModels +from ninja.params.models import ( + Body, + File, + Form, + Param, + Path, + Query, + TModel, + TModels, + _MultiPartBody, +) from ninja.signature.utils import get_path_param_names, get_typed_signature __all__ = [ @@ -204,7 +213,7 @@ def _get_param_type(self, name: str, arg: inspect.Parameter) -> FuncParam: if get_origin(annotation) is Annotated: args = get_args(annotation) - if isinstance(args[1], params.Param): + if isinstance(args[1], Param): prev_default = default annotation, default = args if prev_default != self.signature.empty: @@ -214,7 +223,7 @@ def _get_param_type(self, name: str, arg: inspect.Parameter) -> FuncParam: if default == self.signature.empty: annotation = str else: - if isinstance(default, params.Param): + if isinstance(default, Param): annotation = type(default.default) else: annotation = type(default) @@ -237,7 +246,7 @@ def _get_param_type(self, name: str, arg: inspect.Parameter) -> FuncParam: return FuncParam(name, name, File(default), annotation, is_collection) # 1) if type of the param is defined as one of the Param's subclasses - we just use that definition - if isinstance(default, params.Param): + if isinstance(default, Param): param_source = default # 2) if param name is a part of the path parameter @@ -245,21 +254,21 @@ def _get_param_type(self, name: str, arg: inspect.Parameter) -> FuncParam: assert ( default == self.signature.empty ), f"'{name}' is a path param, default not allowed" - param_source = params.Path(...) + param_source = Path(...) # 3) if param is a collection, or annotation is part of pydantic model: elif is_collection or is_pydantic_model(annotation): if default == self.signature.empty: - param_source = params.Body(...) + param_source = Body(...) else: - param_source = params.Body(default) + param_source = Body(default) # 4) the last case is query param else: if default == self.signature.empty: - param_source = params.Query(...) + param_source = Query(...) else: - param_source = params.Query(default) + param_source = Query(default) return FuncParam( name, param_source.alias or name, param_source, annotation, is_collection diff --git a/tests/mypy_test.py b/tests/mypy_test.py new file mode 100644 index 000000000..d926e9933 --- /dev/null +++ b/tests/mypy_test.py @@ -0,0 +1,36 @@ +# The goal of this file is to test that mypy "likes" all the combinations of parametrization +from typing import Any + +from django.http import HttpRequest +from typing_extensions import Annotated + +from ninja import Body, BodyEx, NinjaAPI, P, Schema + + +class Payload(Schema): + x: int + y: float + s: str + + +api = NinjaAPI() + + +@api.post("/old_way") +def old_way(request: HttpRequest, data: Payload = Body()) -> Any: + data.s.capitalize() + + +@api.post("/annotated_way") +def annotated_way(request: HttpRequest, data: Annotated[Payload, Body()]) -> Any: + data.s.capitalize() + + +@api.post("/new_way") +def new_way(request: HttpRequest, data: Body[Payload]) -> Any: + data.s.capitalize() + + +@api.post("/new_way_ex") +def new_way_ex(request: HttpRequest, data: BodyEx[Payload, P(title="A title")]) -> Any: + data.s.find("")