Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: ⚙️ deprecate Python 3.8, modernize type hints for Python 3.9+ #498

Merged
Merged
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
6 changes: 1 addition & 5 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,13 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
django-version: ['4.2.*', '5.0.*', '5.1.*']
django-ninja-version: ['1.0.*', '1.1.*', '1.2.*', '1.3.*']
django-rest-testing: ['0.1.*']
exclude:
- python-version: '3.8'
django-version: '5.0.*'
- python-version: '3.9'
django-version: '5.0.*'
- python-version: '3.8'
django-version: '5.1.*'
- python-version: '3.9'
django-version: '5.1.*'

Expand Down
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@ repos:
- id: trailing-whitespace

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
rev: v0.7.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format

- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.2
rev: v1.12.1
hooks:
- id: mypy
files: ^ninja_crud/
files: ^src/ninja_crud/
args: [--ignore-missing-imports, --strict]
additional_dependencies: ['types-PyYAML', 'django-stubs', 'pydantic']

Expand Down
6 changes: 3 additions & 3 deletions examples/reusable_views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, Optional, Type
from typing import Any, Optional
from uuid import UUID

from django.db import models
Expand All @@ -10,7 +10,7 @@

class ReusableReadView(APIView):
def __init__(
self, response_schema: Any = NOT_SET, model: Optional[Type[models.Model]] = None
self, response_schema: Any = NOT_SET, model: Optional[type[models.Model]] = None
) -> None:
super().__init__(
"/{id}/reusable", methods=["GET"], response_schema=response_schema
Expand All @@ -23,7 +23,7 @@ def handler(self, request: HttpRequest, id: UUID) -> models.Model:

class ReusableAsyncReadView(APIView):
def __init__(
self, response_schema: Any = NOT_SET, model: Optional[Type[models.Model]] = None
self, response_schema: Any = NOT_SET, model: Optional[type[models.Model]] = None
) -> None:
super().__init__(
"/{id}/reusable/async", methods=["GET"], response_schema=response_schema
Expand Down
4 changes: 2 additions & 2 deletions examples/schemas.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from datetime import date
from typing import Generic, List, Optional, TypeVar
from typing import Generic, Optional, TypeVar
from uuid import UUID

from ninja import Schema
Expand Down Expand Up @@ -32,5 +32,5 @@ class EmployeeOut(Schema):


class Paged(Schema, Generic[T]):
items: List[T]
items: list[T]
count: int
4 changes: 1 addition & 3 deletions examples/views/department_views.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from typing import List

from ninja import Router

from examples import reusable_views
Expand Down Expand Up @@ -27,7 +25,7 @@ class DepartmentViewSet(viewsets.APIViewSet):
get_queryset=lambda request, path_parameters: Employee.objects.filter(
department_id=path_parameters.id
),
response_body=List[EmployeeOut],
response_body=list[EmployeeOut],
)
create_employee = views.CreateView(
path="/{id}/employees/",
Expand Down
15 changes: 7 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,21 @@
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/ninja_crud"]

[project]
name = "django-ninja-crud"
version = "0.6.2"
description = "🧩 Modular, composable API views for scalable Django Ninja projects, with built-in CRUD."
readme = "README.md"
requires-python = ">=3.8"
requires-python = ">=3.9"
license = {file = "LICENSE"}
authors = [{name = "Hicham Bakri", email = "[email protected]"}]
maintainers = [{name = "Hicham Bakri", email = "[email protected]"}]
keywords = ["django", "ninja", "crud", "api", "rest", "framework", "asyncio", "async"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Development Status :: 4 - Beta",
"Topic :: Software Development",
"Topic :: Software Development :: Libraries",
"Programming Language :: Python",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
Expand All @@ -34,6 +28,8 @@ classifiers = [
"Framework :: Django :: 5.1",
"Framework :: AsyncIO",
"Framework :: Pydantic",
"Topic :: Software Development",
"Topic :: Software Development :: Libraries",
"Typing :: Typed",
]
dependencies = [
Expand All @@ -46,6 +42,9 @@ Homepage = "https://github.com/hbakri/django-ninja-crud"
Repository = "https://github.com/hbakri/django-ninja-crud"
Documentation = "https://django-ninja-crud.readme.io"

[tool.hatch.build.targets.wheel]
packages = ["src/ninja_crud"]

[tool.uv]
dev-dependencies = [
"coverage>=7.6.0,<8.0",
Expand Down
58 changes: 27 additions & 31 deletions src/ninja_crud/views/api_view.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import abc
import asyncio
import functools
import typing
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Type, Union
from typing import TYPE_CHECKING, Any, Callable, Optional, Union, get_args, get_origin

import django.db.models
import ninja
Expand Down Expand Up @@ -48,12 +47,12 @@ class APIView(abc.ABC):
returned by this *path operation*. The key is the status code, and the value
is the response schema. These are merged with the primary response.
Defaults to `None`.
name (str | None, optional): The name of the view function, used by the OpenAPI
documentation. If not provided, defaults to the class attribute name if
the view is part of a viewset. Defaults to `None`.
decorators (list[Callable] | None, optional): View function decorators
name (str, optional): The name of the view function, used by the OpenAPI docs.
If not provided, defaults to the class attribute name if the view is part
of a viewset. Defaults to `None`.
decorators (list[Callable], optional): View function decorators
(applied in reverse order). Defaults to `None`.
operation_kwargs (dict[str, Any] | None, optional): Additional operation
operation_kwargs (dict[str, Any], optional): Additional operation
keyword arguments. Defaults to `None`.

Examples:
Expand All @@ -65,7 +64,7 @@ class APIView(abc.ABC):

Synchronous:
```python
from typing import Optional, Type, Any
from typing import Any
from uuid import UUID

from django.http import HttpRequest
Expand All @@ -77,7 +76,7 @@ class ReadView(APIView):
def __init__(
self,
response_schema: Any = NOT_SET,
model: Optional[Type[models.Model]] = None,
model: type[models.Model] | None = None,
name: Optional[str] = None,
) -> None:
super().__init__(
Expand All @@ -99,7 +98,7 @@ class AsyncReadView(APIView):
def __init__(
self,
response_schema: Any = NOT_SET,
model: Optional[Type[models.Model]] = None,
model: type[models.Model] | None = None,
name: Optional[str] = None,
) -> None:
super().__init__(
Expand Down Expand Up @@ -189,14 +188,14 @@ class DepartmentViewSet(APIViewSet):
def __init__(
self,
path: str,
methods: Union[List[str], Set[str]],
methods: Union[list[str], set[str]],
*,
response_schema: Any = NOT_SET,
status_code: Optional[int] = None,
responses: Optional[Dict[int, Any]] = None,
responses: Optional[dict[int, Any]] = None,
name: Optional[str] = None,
decorators: Optional[List[Decorator]] = None,
operation_kwargs: Optional[Dict[str, Any]] = None,
decorators: Optional[list[Decorator]] = None,
operation_kwargs: Optional[dict[str, Any]] = None,
) -> None:
self.path = path
self.methods = methods
Expand All @@ -206,7 +205,7 @@ def __init__(
self.name = name
self.decorators = decorators or []
self.operation_kwargs = operation_kwargs or {}
self._api_viewset_class: Optional[Type[APIViewSet]] = None
self._api_viewset_class: Optional[type[APIViewSet]] = None

def add_view_to(self, api_or_router: Union[ninja.NinjaAPI, ninja.Router]) -> None:
"""
Expand All @@ -215,7 +214,7 @@ def add_view_to(self, api_or_router: Union[ninja.NinjaAPI, ninja.Router]) -> Non
convert the view to an API operation dictionary (see `as_operation`).

Args:
api_or_router (Union[ninja.NinjaAPI, ninja.Router]): The API or router to
api_or_router (ninja.NinjaAPI | ninja.Router): The API or router to
add the view to.

Example:
Expand All @@ -238,14 +237,14 @@ def add_view_to(self, api_or_router: Union[ninja.NinjaAPI, ninja.Router]) -> Non

router.add_api_operation(**self.as_operation())

def as_operation(self) -> Dict[str, Any]:
def as_operation(self) -> dict[str, Any]:
"""
Return a dictionary representation of the API operation that can be added to a
router or API by using `add_api_operation` method. Used internally by
`add_view_to`, but can also be called manually to add the view to a router.

Returns:
Dict[str, Any]: The API operation dictionary.
dict[str, Any]: The API operation dictionary.

Example:
```python
Expand All @@ -261,7 +260,7 @@ def as_operation(self) -> Dict[str, Any]:
router.add_api_operation(**read_department.as_operation())
```
"""
responses: Dict[int, Any] = {}
responses: dict[int, Any] = {}
if self.response_schema is not NOT_SET:
responses[self.status_code or 200] = self.response_schema
elif self.status_code is not None:
Expand Down Expand Up @@ -328,11 +327,11 @@ def sync_handler(*args: Any, **kwargs: Any) -> Any:
return standalone_handler

@property
def api_viewset_class(self) -> Optional[Type["APIViewSet"]]:
def api_viewset_class(self) -> Optional[type["APIViewSet"]]:
return self._api_viewset_class

@api_viewset_class.setter
def api_viewset_class(self, api_viewset_class: Type["APIViewSet"]) -> None:
def api_viewset_class(self, api_viewset_class: type["APIViewSet"]) -> None:
if self._api_viewset_class:
raise ValueError(
f"View '{self.name}' is already bound to a viewset. "
Expand All @@ -341,8 +340,8 @@ def api_viewset_class(self, api_viewset_class: Type["APIViewSet"]) -> None:
self._api_viewset_class = api_viewset_class

def resolve_path_parameters(
self, model: Optional[Type[django.db.models.Model]]
) -> Optional[Type[pydantic.BaseModel]]:
self, model: type[django.db.models.Model]
) -> Optional[type[pydantic.BaseModel]]:
"""
Resolve path parameters to a pydantic model based on the view's path and model.

Expand All @@ -359,10 +358,10 @@ def resolve_path_parameters(
fields. Returns `None` if no parameters are found.

Args:
model (Optional[Type[django.db.models.Model]]): The associated Django model.
model (type[django.db.models.Model] | None): The associated Django model.

Returns:
Optional[Type[pydantic.BaseModel]]: Path parameters pydantic model type.
type[pydantic.BaseModel] | None: Path parameters pydantic model type.

Example:
For path `"/{department_id}/employees/{id}"` and `Employee` model:
Expand All @@ -384,20 +383,17 @@ class PathParameters(pydantic.BaseModel):
Prefer simple types (strings, integers, UUIDs) for path parameters to
ensure proper URL formatting and web standard compatibility.
"""
if model is None:
return None

path_parameters_names = ninja.signature.utils.get_path_param_names(self.path)
if not path_parameters_names:
return None

schema_fields: Dict[str, Any] = {}
schema_fields: dict[str, Any] = {}
for field_name in path_parameters_names:
model_field = model._meta.get_field(field_name)
schema_field = ninja.orm.fields.get_schema_field(field=model_field)[0]
schema_fields[field_name] = (
typing.get_args(schema_field)[0]
if typing.get_origin(schema_field) is Union
get_args(schema_field)[0]
if get_origin(schema_field) is Union
else schema_field,
...,
)
Expand Down
Loading