diff --git a/.circleci/config.yml b/.circleci/config.yml index 6b21f2e5..7ff0eeda 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -5,7 +5,7 @@ orbs: jobs: build: docker: - - image: cimg/python:3.10 + - image: cimg/python:3.9 steps: - checkout @@ -31,7 +31,7 @@ jobs: coverage: docker: - - image: cimg/python:3.10 + - image: cimg/python:3.9 steps: - checkout - attach_workspace: diff --git a/{{cookiecutter.project_slug}}/Makefile b/{{cookiecutter.project_slug}}/Makefile index 5620c78c..e0f124b2 100644 --- a/{{cookiecutter.project_slug}}/Makefile +++ b/{{cookiecutter.project_slug}}/Makefile @@ -12,6 +12,7 @@ dev-deps: deps lint: flake8 src + cd src && mypy test: cd src && pytest --dead-fixtures diff --git a/{{cookiecutter.project_slug}}/dev-requirements.in b/{{cookiecutter.project_slug}}/dev-requirements.in index 9ab3aac8..ae96b705 100644 --- a/{{cookiecutter.project_slug}}/dev-requirements.in +++ b/{{cookiecutter.project_slug}}/dev-requirements.in @@ -37,3 +37,10 @@ flake8-todo flake8-use-fstring flake8-variables-names flake8-walrus + + +mypy +django-stubs +djangorestframework-stubs +types-freezegun +types-Pillow diff --git a/{{cookiecutter.project_slug}}/dev-requirements.txt b/{{cookiecutter.project_slug}}/dev-requirements.txt index 7dfb09fb..75bfb571 100644 --- a/{{cookiecutter.project_slug}}/dev-requirements.txt +++ b/{{cookiecutter.project_slug}}/dev-requirements.txt @@ -1,13 +1,19 @@ # -# This file is autogenerated by pip-compile +# This file is autogenerated by pip-compile with python 3.9 # To update, run: # # pip-compile dev-requirements.in # appnope==0.1.2 # via ipython +asgiref==3.5.0 + # via + # -c requirements.txt + # django astor==0.8.1 # via flake8-simplify +asttokens==2.0.5 + # via stack-data attrs==21.4.0 # via # flake8-bugbear @@ -16,14 +22,67 @@ attrs==21.4.0 # pytest backcall==0.2.0 # via ipython +black==22.1.0 + # via ipython +certifi==2021.10.8 + # via + # -c requirements.txt + # requests +charset-normalizer==2.0.12 + # via + # -c requirements.txt + # requests +click==8.0.4 + # via black cognitive-complexity==1.2.0 # via flake8-cognitive-complexity +coreapi==2.3.3 + # via + # -c requirements.txt + # djangorestframework-stubs +coreschema==0.0.4 + # via + # -c requirements.txt + # coreapi decorator==5.1.1 # via ipython +django==3.2.12 + # via + # -c requirements.txt + # django-stubs + # django-stubs-ext +django-stubs==1.9.0 + # via + # -r dev-requirements.in + # djangorestframework-stubs +django-stubs-ext==0.3.1 + # via django-stubs +djangorestframework-stubs==1.4.0 + # via -r dev-requirements.in eradicate==2.0.0 # via flake8-eradicate +executing==0.8.2 + # via stack-data faker==8.16.0 # via mixer +flake8==3.9.2 + # via + # flake8-absolute-import + # flake8-bugbear + # flake8-commas + # flake8-django + # flake8-eradicate + # flake8-isort + # flake8-multiline-containers + # flake8-mutable + # flake8-pep3101 + # flake8-print + # flake8-printf-formatting + # flake8-pytest + # flake8-quotes + # flake8-simplify + # flake8-use-fstring + # flake8-walrus flake8-absolute-import==1.0.0.1 # via -r dev-requirements.in flake8-bugbear==21.11.29 @@ -56,10 +115,10 @@ flake8-print==4.0.0 # via -r dev-requirements.in flake8-printf-formatting==1.1.2 # via -r dev-requirements.in -flake8-pytest-style==1.6.0 - # via -r dev-requirements.in flake8-pytest==1.3 # via -r dev-requirements.in +flake8-pytest-style==1.6.0 + # via -r dev-requirements.in flake8-quotes==3.3.1 # via -r dev-requirements.in flake8-simplify==0.18.0 @@ -72,28 +131,14 @@ flake8-variables-names==0.0.4 # via -r dev-requirements.in flake8-walrus==1.1.0 # via -r dev-requirements.in -flake8==3.9.2 - # via - # flake8-absolute-import - # flake8-bugbear - # flake8-commas - # flake8-django - # flake8-eradicate - # flake8-isort - # flake8-multiline-containers - # flake8-mutable - # flake8-pep3101 - # flake8-print - # flake8-printf-formatting - # flake8-pytest - # flake8-quotes - # flake8-simplify - # flake8-use-fstring - # flake8-walrus freezegun==1.1.0 # via # -r dev-requirements.in # pytest-freezegun +idna==3.3 + # via + # -c requirements.txt + # requests importlib-metadata==4.11.1 # via pytest-randomly iniconfig==1.1.1 @@ -102,32 +147,59 @@ ipython==8.0.1 # via -r dev-requirements.in isort==5.10.1 # via flake8-isort +itypes==1.2.0 + # via + # -c requirements.txt + # coreapi jedi==0.18.1 # via # -r dev-requirements.in # ipython +jinja2==3.0.3 + # via + # -c requirements.txt + # coreschema +markupsafe==2.1.0 + # via + # -c requirements.txt + # jinja2 matplotlib-inline==0.1.3 # via ipython -mccabe==0.7.0 +mccabe==0.6.1 # via flake8 mixer==7.2.1 # via -r dev-requirements.in +mypy==0.931 + # via + # -r dev-requirements.in + # django-stubs + # djangorestframework-stubs +mypy-extensions==0.4.3 + # via + # black + # mypy packaging==21.3 # via # -c requirements.txt # pytest parso==0.8.3 # via jedi +pathspec==0.9.0 + # via black pexpect==4.8.0 # via ipython pickleshare==0.7.5 # via ipython +platformdirs==2.5.1 + # via black pluggy==1.0.0 # via pytest prompt-toolkit==3.0.28 # via ipython ptyprocess==0.7.0 # via pexpect +pure-eval==0.2.2 + # via stack-data py==1.11.0 # via pytest pycodestyle==2.7.0 @@ -143,6 +215,14 @@ pyparsing==2.4.7 # via # -c requirements.txt # packaging +pytest==7.0.1 + # via + # pytest-deadfixtures + # pytest-django + # pytest-env + # pytest-freezegun + # pytest-mock + # pytest-randomly pytest-deadfixtures==2.2.1 # via -r dev-requirements.in pytest-django==4.5.2 @@ -155,35 +235,70 @@ pytest-mock==3.7.0 # via -r dev-requirements.in pytest-randomly==3.11.0 # via -r dev-requirements.in -pytest==7.0.1 - # via - # pytest-deadfixtures - # pytest-django - # pytest-env - # pytest-freezegun - # pytest-mock - # pytest-randomly python-dateutil==2.8.2 # via # faker # freezegun +pytz==2021.3 + # via + # -c requirements.txt + # django +requests==2.27.1 + # via + # -c requirements.txt + # coreapi + # djangorestframework-stubs six==1.16.0 # via # -c requirements.txt + # asttokens # flake8-print # python-dateutil +sqlparse==0.4.2 + # via + # -c requirements.txt + # django +stack-data==0.2.0 + # via ipython testfixtures==6.18.3 # via flake8-isort text-unidecode==1.3 # via faker toml==0.10.2 - # via pytest + # via django-stubs +tomli==2.0.1 + # via + # black + # mypy + # pytest traitlets==5.1.1 # via # ipython # matplotlib-inline +types-freezegun==1.1.6 + # via -r dev-requirements.in +types-pillow==9.0.6 + # via -r dev-requirements.in +types-pytz==2021.3.5 + # via django-stubs +types-pyyaml==6.0.4 + # via django-stubs typing-extensions==3.10.0.2 - # via flake8-pie + # via + # black + # django-stubs + # django-stubs-ext + # djangorestframework-stubs + # flake8-pie + # mypy +uritemplate==3.0.1 + # via + # -c requirements.txt + # coreapi +urllib3==1.26.8 + # via + # -c requirements.txt + # requests wcwidth==0.2.5 # via prompt-toolkit zipp==3.7.0 diff --git a/{{cookiecutter.project_slug}}/requirements.txt b/{{cookiecutter.project_slug}}/requirements.txt index fbfe700c..6b33b9d2 100644 --- a/{{cookiecutter.project_slug}}/requirements.txt +++ b/{{cookiecutter.project_slug}}/requirements.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile +# This file is autogenerated by pip-compile with python 3.9 # To update, run: # # pip-compile requirements.in @@ -26,6 +26,8 @@ coreschema==0.0.4 # drf-yasg cryptography==3.4.8 # via pyjwt +deprecated==1.2.13 + # via redis django==3.2.12 # via # -r requirements.in @@ -76,7 +78,9 @@ jinja2==3.0.3 markupsafe==2.1.0 # via jinja2 packaging==21.3 - # via drf-yasg + # via + # drf-yasg + # redis pillow==9.0.1 # via -r requirements.in pycparser==2.21 @@ -117,6 +121,8 @@ urllib3==1.26.8 # sentry-sdk whitenoise==5.3.0 # via -r requirements.in +wrapt==1.13.3 + # via deprecated # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/{{cookiecutter.project_slug}}/src/a12n/utils.py b/{{cookiecutter.project_slug}}/src/a12n/utils.py index 69085b2f..33e68e3a 100644 --- a/{{cookiecutter.project_slug}}/src/a12n/utils.py +++ b/{{cookiecutter.project_slug}}/src/a12n/utils.py @@ -1,7 +1,9 @@ from rest_framework_jwt.settings import api_settings +from users.models import User -def get_jwt(user) -> str: + +def get_jwt(user: User) -> str: """Make JWT for given user""" jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER diff --git a/{{cookiecutter.project_slug}}/src/app/admin/__init__.py b/{{cookiecutter.project_slug}}/src/app/admin/__init__.py index f370e320..b37178f2 100644 --- a/{{cookiecutter.project_slug}}/src/app/admin/__init__.py +++ b/{{cookiecutter.project_slug}}/src/app/admin/__init__.py @@ -3,6 +3,6 @@ from app.admin.model_admin import ModelAdmin __all__ = [ - admin, - ModelAdmin, + 'admin', + 'ModelAdmin', ] diff --git a/{{cookiecutter.project_slug}}/src/app/api/throttling.py b/{{cookiecutter.project_slug}}/src/app/api/throttling.py index 4a29aeae..fa42f3c5 100644 --- a/{{cookiecutter.project_slug}}/src/app/api/throttling.py +++ b/{{cookiecutter.project_slug}}/src/app/api/throttling.py @@ -1,9 +1,19 @@ +from typing import Protocol + +from rest_framework.request import Request +from rest_framework.views import APIView + from django.conf import settings +class BaseThrottle(Protocol): + def allow_request(self, request: Request, view: APIView) -> bool: + ... + + class ConfigurableThrottlingMixin: - def allow_request(self, *args, **kwargs): + def allow_request(self: BaseThrottle, request: Request, view: APIView) -> bool: if settings.DISABLE_THROTTLING: return True - return super().allow_request(*args, **kwargs) + return super().allow_request(request, view) # type: ignore diff --git a/{{cookiecutter.project_slug}}/src/app/api/viewsets.py b/{{cookiecutter.project_slug}}/src/app/api/viewsets.py index c7474767..e69de29b 100644 --- a/{{cookiecutter.project_slug}}/src/app/api/viewsets.py +++ b/{{cookiecutter.project_slug}}/src/app/api/viewsets.py @@ -1,93 +0,0 @@ -from rest_framework import mixins -from rest_framework import status -from rest_framework.mixins import CreateModelMixin -from rest_framework.mixins import UpdateModelMixin -from rest_framework.response import Response -from rest_framework.viewsets import GenericViewSet - -__all__ = ['DefaultModelViewSet'] - - -class DefaultCreateModelMixin(CreateModelMixin): - """Return detail-serialized created instance""" - - def create(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - instance = self.perform_create(serializer) # No getting created instance in original DRF - headers = self.get_success_headers(serializer.data) - return self.response(instance, status.HTTP_201_CREATED, headers) - - def perform_create(self, serializer): - return serializer.save() # No returning created instance in original DRF - - -class DefaultUpdateModelMixin(UpdateModelMixin): - """Return detail-serialized updated instance""" - - def update(self, request, *args, **kwargs): - partial = kwargs.pop('partial', False) - instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data, partial=partial) - serializer.is_valid(raise_exception=True) - instance = self.perform_update(serializer) # No getting updated instance in original DRF - - if getattr(instance, '_prefetched_objects_cache', None): - # If 'prefetch_related' has been applied to a queryset, we need to - # forcibly invalidate the prefetch cache on the instance. - instance._prefetched_objects_cache = {} - - return self.response(instance, status.HTTP_200_OK) - - def perform_update(self, serializer): - return serializer.save() # No returning updated instance in original DRF - - -class ResponseWithRetrieveSerializerMixin: - """ - Always response with 'retrieve' serializer or fallback to `serializer_class`. - Usage: - - class MyViewSet(DefaultModelViewSet): - serializer_class = MyDefaultSerializer - serializer_action_classes = { - 'list': MyListSerializer, - 'my_action': MyActionSerializer, - } - @action - def my_action: - ... - - 'my_action' request will be validated with MyActionSerializer, - but response will be serialized with MyDefaultSerializer - (or 'retrieve' if provided). - - Thanks gonz: http://stackoverflow.com/a/22922156/11440 - - """ - def response(self, instance, status, headers=None): - retrieve_serializer_class = self.get_serializer_class(action='retrieve') - context = self.get_serializer_context() - retrieve_serializer = retrieve_serializer_class(instance, context=context) - return Response(retrieve_serializer.data, status=status, headers=headers) - - def get_serializer_class(self, action=None): - if action is None: - action = self.action - - try: - return self.serializer_action_classes[action] - except (KeyError, AttributeError): - return super().get_serializer_class() - - -class DefaultModelViewSet( - DefaultCreateModelMixin, # Create response is overriden - mixins.RetrieveModelMixin, - DefaultUpdateModelMixin, # Update response is overriden - mixins.DestroyModelMixin, - mixins.ListModelMixin, - ResponseWithRetrieveSerializerMixin, # Response with retrieve or default serializer - GenericViewSet, -): - pass diff --git a/{{cookiecutter.project_slug}}/src/app/base_config.py b/{{cookiecutter.project_slug}}/src/app/base_config.py index 0915b167..6cd2a3bd 100644 --- a/{{cookiecutter.project_slug}}/src/app/base_config.py +++ b/{{cookiecutter.project_slug}}/src/app/base_config.py @@ -14,7 +14,7 @@ class AppConfig(BaseAppConfig): Allthough, if you wish to use signals, place handlers to the `signals/handlers.py`: your code be automatically imported and used. """ - def ready(self): + def ready(self) -> None: """Import a module with handlers if it exists to avoid boilerplate code.""" with contextlib.suppress(ModuleNotFoundError): - importlib.import_module('.signals.handlers', self.module.__name__) + importlib.import_module('.signals.handlers', self.module.__name__) # type: ignore diff --git a/{{cookiecutter.project_slug}}/src/app/conf/environ.py b/{{cookiecutter.project_slug}}/src/app/conf/environ.py index 6cac6b7f..e28bdf5b 100644 --- a/{{cookiecutter.project_slug}}/src/app/conf/environ.py +++ b/{{cookiecutter.project_slug}}/src/app/conf/environ.py @@ -1,5 +1,5 @@ """Read .env file""" -import environ +import environ # type: ignore env = environ.Env( DEBUG=(bool, False), diff --git a/{{cookiecutter.project_slug}}/src/app/factory.py b/{{cookiecutter.project_slug}}/src/app/factory.py index 8c4b2686..32e02882 100644 --- a/{{cookiecutter.project_slug}}/src/app/factory.py +++ b/{{cookiecutter.project_slug}}/src/app/factory.py @@ -5,10 +5,11 @@ from django.core.files.uploadedfile import SimpleUploadedFile from app.testing import register +from app.testing.types import FactoryProtocol @register -def uploaded_image(self): +def uploaded_image(self: FactoryProtocol) -> SimpleUploadedFile: """ Can be used in both DRF API and mixer. DRF won't let you use invalid image, so the content is a real image. diff --git a/{{cookiecutter.project_slug}}/src/app/fixtures/api.py b/{{cookiecutter.project_slug}}/src/app/fixtures/api.py index 28b5dfab..64be3ec7 100644 --- a/{{cookiecutter.project_slug}}/src/app/fixtures/api.py +++ b/{{cookiecutter.project_slug}}/src/app/fixtures/api.py @@ -1,13 +1,14 @@ import pytest from app.testing import ApiClient +from users.models import User @pytest.fixture -def as_anon(): +def as_anon() -> ApiClient: return ApiClient() @pytest.fixture -def as_user(user): +def as_user(user: User) -> ApiClient: return ApiClient(user=user) diff --git a/{{cookiecutter.project_slug}}/src/app/fixtures/factory.py b/{{cookiecutter.project_slug}}/src/app/fixtures/factory.py index 0a2a3490..aa4c777e 100644 --- a/{{cookiecutter.project_slug}}/src/app/fixtures/factory.py +++ b/{{cookiecutter.project_slug}}/src/app/fixtures/factory.py @@ -4,5 +4,5 @@ @pytest.fixture -def factory(): +def factory() -> FixtureFactory: return FixtureFactory() diff --git a/{{cookiecutter.project_slug}}/src/app/fixtures/image.py b/{{cookiecutter.project_slug}}/src/app/fixtures/image.py index 169291d0..3f86fc46 100644 --- a/{{cookiecutter.project_slug}}/src/app/fixtures/image.py +++ b/{{cookiecutter.project_slug}}/src/app/fixtures/image.py @@ -1,6 +1,12 @@ import pytest +from typing import TYPE_CHECKING + +from django.core.files.uploadedfile import SimpleUploadedFile + +if TYPE_CHECKING: + from app.testing.factory import FixtureFactory @pytest.fixture -def uploaded_image(factory): +def uploaded_image(factory: 'FixtureFactory') -> SimpleUploadedFile: return factory.uploaded_image() diff --git a/{{cookiecutter.project_slug}}/src/app/fixtures/media.py b/{{cookiecutter.project_slug}}/src/app/fixtures/media.py index 4da1ff61..61e2d600 100644 --- a/{{cookiecutter.project_slug}}/src/app/fixtures/media.py +++ b/{{cookiecutter.project_slug}}/src/app/fixtures/media.py @@ -2,12 +2,13 @@ import pytest import shutil import tempfile +from typing import Generator from django.conf import settings @pytest.fixture(scope='session', autouse=True) -def _temporary_media(): +def _temporary_media() -> Generator[None, None, None]: settings.MEDIA_ROOT = Path(tempfile.gettempdir(), 'app/testmedia') Path(settings.MEDIA_ROOT).mkdir(parents=True, exist_ok=True) yield diff --git a/{{cookiecutter.project_slug}}/src/app/middleware/real_ip.py b/{{cookiecutter.project_slug}}/src/app/middleware/real_ip.py index 663196bd..92f2a4f7 100644 --- a/{{cookiecutter.project_slug}}/src/app/middleware/real_ip.py +++ b/{{cookiecutter.project_slug}}/src/app/middleware/real_ip.py @@ -1,7 +1,12 @@ +from typing import Callable + from ipware import get_client_ip +from django.http import HttpRequest +from django.http import HttpResponse + -def real_ip_middleware(get_response): +def real_ip_middleware(get_response: Callable) -> Callable: """Set request.META['REMOTE_ADDR'] to ip guessed by django-ipware. We need this to make sure all apps using ip detection in django way stay usable behind @@ -9,7 +14,7 @@ def real_ip_middleware(get_response): For custom proxy configuration check out django-ipware docs at https://github.com/un33k/django-ipware """ - def middleware(request): + def middleware(request: HttpRequest) -> HttpResponse: request.META['REMOTE_ADDR'] = get_client_ip(request)[0] return get_response(request) diff --git a/{{cookiecutter.project_slug}}/src/app/models.py b/{{cookiecutter.project_slug}}/src/app/models.py index 23629edf..50234308 100644 --- a/{{cookiecutter.project_slug}}/src/app/models.py +++ b/{{cookiecutter.project_slug}}/src/app/models.py @@ -1,4 +1,6 @@ -from behaviors.behaviors import Timestamped +from typing import Any + +from behaviors.behaviors import Timestamped # type: ignore from django.contrib.contenttypes.models import ContentType from django.db import models @@ -16,8 +18,9 @@ class Meta: def __str__(self) -> str: """Default name for all models""" - if hasattr(self, 'name'): - return str(self.name) + name = getattr(self, 'name', None) + if name is not None: + return str(name) return super().__str__() @@ -25,13 +28,13 @@ def __str__(self) -> str: def get_contenttype(cls) -> ContentType: return ContentType.objects.get_for_model(cls) - def update_from_kwargs(self, **kwargs): + def update_from_kwargs(self, **kwargs: dict[str, Any]) -> None: """A shortcut method to update model instance from the kwargs. """ for (key, value) in kwargs.items(): setattr(self, key, value) - def setattr_and_save(self, key, value): + def setattr_and_save(self, key: str, value: Any) -> None: """Shortcut for testing -- set attribute of the model and save""" setattr(self, key, value) self.save() diff --git a/{{cookiecutter.project_slug}}/src/app/services.py b/{{cookiecutter.project_slug}}/src/app/services.py index 91d81e94..9a7b68a6 100644 --- a/{{cookiecutter.project_slug}}/src/app/services.py +++ b/{{cookiecutter.project_slug}}/src/app/services.py @@ -1,4 +1,9 @@ -class BaseService: +from abc import ABCMeta +from abc import abstractmethod +from typing import Callable + + +class BaseService(metaclass=ABCMeta): """This is a template of a a base service. All services in the app should follow this rules: * Input variables should be done at the __init__ phase @@ -22,17 +27,18 @@ def __call__(self, first_name: str, last_name: Optional[str]) -> User: For more implementation examples, check out https://github.com/tough-dev-school/education-backend/tree/master/src/orders/services """ - def __call__(self): + def __call__(self) -> None: self.validate() return self.act() - def get_validators(self): + def get_validators(self) -> list[Callable]: return [] - def validate(self): + def validate(self) -> None: validators = self.get_validators() for validator in validators: validator() - def act(self): - return + @abstractmethod + def act(self) -> None: + raise NotImplementedError('Please implement in the service class') diff --git a/{{cookiecutter.project_slug}}/src/app/testing/api.py b/{{cookiecutter.project_slug}}/src/app/testing/api.py index 4c15147e..885ea94f 100644 --- a/{{cookiecutter.project_slug}}/src/app/testing/api.py +++ b/{{cookiecutter.project_slug}}/src/app/testing/api.py @@ -1,13 +1,16 @@ import json import random import string +from typing import Optional from rest_framework.authtoken.models import Token -from rest_framework.test import APIClient +from rest_framework.test import APIClient as DRFAPIClient +from users.models import User -class ApiClient(APIClient): - def __init__(self, user=None, *args, **kwargs): + +class ApiClient(DRFAPIClient): + def __init__(self, user: Optional[User] = None, *args, **kwargs) -> None: super().__init__(*args, **kwargs) if user: @@ -69,3 +72,8 @@ def is_json(response) -> bool: return 'json' in response.get('content-type') return False + + +__all__ = [ + 'ApiClient', +] diff --git a/{{cookiecutter.project_slug}}/src/app/testing/factory.py b/{{cookiecutter.project_slug}}/src/app/testing/factory.py index fe178ae3..b0cf4a87 100644 --- a/{{cookiecutter.project_slug}}/src/app/testing/factory.py +++ b/{{cookiecutter.project_slug}}/src/app/testing/factory.py @@ -1,18 +1,19 @@ from functools import partial +from typing import Callable from app.testing.mixer import mixer -def register(method): +def register(method: Callable) -> Callable: name = method.__name__ FixtureRegistry.METHODS[name] = method return method class FixtureRegistry: - METHODS = {} + METHODS: dict[str, Callable] = {} - def get(self, name): + def get(self, name: str) -> Callable: method = self.METHODS.get(name) if not method: raise AttributeError(f'Factory method “{name}” not found.') @@ -20,24 +21,24 @@ def get(self, name): class CycleFixtureFactory: - def __init__(self, factory: 'FixtureFactory', count: int): + def __init__(self, factory: 'FixtureFactory', count: int) -> None: self.factory = factory self.count = count - def __getattr__(self, name): + def __getattr__(self, name: str) -> Callable: return lambda *args, **kwargs: [getattr(self.factory, name)(*args, **kwargs) for _ in range(self.count)] class FixtureFactory: - def __init__(self): + def __init__(self) -> None: self.mixer = mixer self.registry = FixtureRegistry() - def __getattr__(self, name): + def __getattr__(self, name: str) -> Callable: method = self.registry.get(name) return partial(method, self) - def cycle(self, count) -> CycleFixtureFactory: + def cycle(self, count: int) -> CycleFixtureFactory: """ Run given method X times: factory.cycle(5).order() # gives 5 orders diff --git a/{{cookiecutter.project_slug}}/src/app/testing/mixer.py b/{{cookiecutter.project_slug}}/src/app/testing/mixer.py index bdf52ee8..ebee3c34 100644 --- a/{{cookiecutter.project_slug}}/src/app/testing/mixer.py +++ b/{{cookiecutter.project_slug}}/src/app/testing/mixer.py @@ -3,7 +3,7 @@ from mixer.backend.django import mixer __all__ = [ - mixer, + 'mixer', ] diff --git a/{{cookiecutter.project_slug}}/src/app/testing/runner.py b/{{cookiecutter.project_slug}}/src/app/testing/runner.py index 7d0add36..79c65b44 100644 --- a/{{cookiecutter.project_slug}}/src/app/testing/runner.py +++ b/{{cookiecutter.project_slug}}/src/app/testing/runner.py @@ -1,3 +1,4 @@ +# type: ignore from django.core.management.base import CommandError diff --git a/{{cookiecutter.project_slug}}/src/app/testing/types.py b/{{cookiecutter.project_slug}}/src/app/testing/types.py new file mode 100644 index 00000000..be0152a8 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/app/testing/types.py @@ -0,0 +1,12 @@ +from typing import Protocol + +from mixer.backend.django import mixer + + +class FactoryProtocol(Protocol): + mixer: mixer + + +__all__ = [ + 'FactoryProtocol', +] diff --git a/{{cookiecutter.project_slug}}/src/manage.py b/{{cookiecutter.project_slug}}/src/manage.py index b9fcfe68..55851fea 100755 --- a/{{cookiecutter.project_slug}}/src/manage.py +++ b/{{cookiecutter.project_slug}}/src/manage.py @@ -4,7 +4,7 @@ import sys -def main(): +def main() -> None: os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') try: from django.core.management import execute_from_command_line diff --git a/{{cookiecutter.project_slug}}/src/mypy.ini b/{{cookiecutter.project_slug}}/src/mypy.ini new file mode 100644 index 00000000..91ddabaa --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/mypy.ini @@ -0,0 +1,54 @@ +[mypy] +python_version = 3.9 +files = . +namespace_packages = on +explicit_package_bases = on +warn_no_return = off +warn_unused_configs = on +warn_unused_ignores = on +warn_redundant_casts = on +no_implicit_optional = on +no_implicit_reexport = on +strict_equality = on +warn_unreachable = on +disallow_untyped_calls = on +disallow_untyped_defs = on +exclude = migrations/ + +plugins = + mypy_drf_plugin.main, + mypy_django_plugin.main + +[mypy.plugins.django-stubs] +django_settings_module = "app.settings" + +[mypy-rest_framework_jwt.*] +ignore_missing_imports = on + +[mypy-app.testing.api.*] +disallow_untyped_defs = off + +[mypy-*.tests.*] +disallow_untyped_defs = off + +[mypy-*.management.*] +disallow_untyped_defs = off + +[mypy-djangorestframework_camel_case.*] +ignore_missing_imports = on + +[mypy-django_filters.*] +ignore_missing_imports = on + +[mypy-drf_yasg.*] +ignore_missing_imports = on + +[mypy-axes.*] +ignore_missing_imports = on + +[mypy-mixer.*] +ignore_missing_imports = on + +[mypy-ipware.*] +ignore_missing_imports = on + diff --git a/{{cookiecutter.project_slug}}/src/sepulkas/api/viewsets.py b/{{cookiecutter.project_slug}}/src/sepulkas/api/viewsets.py index d754fd7c..b9841ac0 100644 --- a/{{cookiecutter.project_slug}}/src/sepulkas/api/viewsets.py +++ b/{{cookiecutter.project_slug}}/src/sepulkas/api/viewsets.py @@ -1,9 +1,10 @@ -from app.api.viewsets import DefaultModelViewSet +from rest_framework.viewsets import ModelViewSet + from sepulkas.api import serializers from sepulkas.models import Sepulka -class SepulkaViewSet(DefaultModelViewSet): +class SepulkaViewSet(ModelViewSet): serializer_class = serializers.SepulkaSerializer serializer_action_classes = { 'create': serializers.SepulkaCreateSerializer, diff --git a/{{cookiecutter.project_slug}}/src/sepulkas/factory.py b/{{cookiecutter.project_slug}}/src/sepulkas/factory.py index 7c09cb11..17338a79 100644 --- a/{{cookiecutter.project_slug}}/src/sepulkas/factory.py +++ b/{{cookiecutter.project_slug}}/src/sepulkas/factory.py @@ -1,6 +1,8 @@ from app.testing import register +from app.testing.types import FactoryProtocol +from sepulkas.models import Sepulka @register -def sepulka(self, **kwargs): +def sepulka(self: FactoryProtocol, **kwargs: dict) -> Sepulka: return self.mixer.blend('sepulkas.Sepulka', **kwargs) diff --git a/{{cookiecutter.project_slug}}/src/sepulkas/models.py b/{{cookiecutter.project_slug}}/src/sepulkas/models.py index 282fff2f..9dcc1245 100644 --- a/{{cookiecutter.project_slug}}/src/sepulkas/models.py +++ b/{{cookiecutter.project_slug}}/src/sepulkas/models.py @@ -10,5 +10,5 @@ class Sepulka(DefaultModel): class Meta: ordering = ['id'] - def __str__(self): + def __str__(self) -> str: return self.title diff --git a/{{cookiecutter.project_slug}}/src/users/api/serializers.py b/{{cookiecutter.project_slug}}/src/users/api/serializers.py index 8eb7c339..7a4279ed 100644 --- a/{{cookiecutter.project_slug}}/src/users/api/serializers.py +++ b/{{cookiecutter.project_slug}}/src/users/api/serializers.py @@ -17,5 +17,5 @@ class Meta: 'remote_addr', ] - def get_remote_addr(self, obj): + def get_remote_addr(self, obj: User) -> str: return self.context['request'].META['REMOTE_ADDR'] diff --git a/{{cookiecutter.project_slug}}/src/users/api/viewsets.py b/{{cookiecutter.project_slug}}/src/users/api/viewsets.py index 0bcd3b7f..940fc068 100644 --- a/{{cookiecutter.project_slug}}/src/users/api/viewsets.py +++ b/{{cookiecutter.project_slug}}/src/users/api/viewsets.py @@ -1,8 +1,11 @@ from drf_yasg.utils import swagger_auto_schema from rest_framework.generics import GenericAPIView from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request from rest_framework.response import Response +from django.db.models import QuerySet + from users.api.serializers import UserSerializer from users.models import User @@ -12,13 +15,14 @@ class SelfView(GenericAPIView): permission_classes = [IsAuthenticated] @swagger_auto_schema(responses={200: UserSerializer}, operation_id='whoami', operation_description='Get current user data') - def get(self, request): + def get(self, request: Request) -> Response: user = self.get_object() serializer = self.get_serializer(user) + return Response(serializer.data) def get_object(self) -> User: return self.get_queryset().get(pk=self.request.user.pk) - def get_queryset(self): + def get_queryset(self) -> QuerySet[User]: return User.objects.filter(is_active=True) diff --git a/{{cookiecutter.project_slug}}/src/users/factory.py b/{{cookiecutter.project_slug}}/src/users/factory.py index ac131b82..40e238c1 100644 --- a/{{cookiecutter.project_slug}}/src/users/factory.py +++ b/{{cookiecutter.project_slug}}/src/users/factory.py @@ -1,13 +1,15 @@ from django.contrib.auth.models import AnonymousUser from app.testing import register +from app.testing.types import FactoryProtocol +from users.models import User @register -def user(self, **kwargs): +def user(self: FactoryProtocol, **kwargs: dict) -> User: return self.mixer.blend('users.User', **kwargs) @register -def anon(self, **kwargs): +def anon(self: FactoryProtocol, **kwargs: dict) -> AnonymousUser: return AnonymousUser() diff --git a/{{cookiecutter.project_slug}}/src/users/fixtures.py b/{{cookiecutter.project_slug}}/src/users/fixtures.py index e0b4db4f..f864d0bf 100644 --- a/{{cookiecutter.project_slug}}/src/users/fixtures.py +++ b/{{cookiecutter.project_slug}}/src/users/fixtures.py @@ -1,6 +1,12 @@ import pytest +from typing import TYPE_CHECKING + +from users.models import User + +if TYPE_CHECKING: + from app.testing.factory import FixtureFactory @pytest.fixture -def user(factory): +def user(factory: 'FixtureFactory') -> User: return factory.user()