Merge pull request #4834 from foarsitter/ruff

Ruff linting & formatting
This commit is contained in:
Jelmer 2024-02-13 14:48:37 +01:00 committed by GitHub
commit 5bc8ac664c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 349 additions and 268 deletions

View File

@ -4,40 +4,30 @@ Linters
.. index:: linters
flake8
ruff
------
To run flake8: ::
Ruff is a Python linter and code formatter, written in Rust.
It is a aggregation of flake8, pylint, pyupgrade and many more.
$ flake8
Ruff comes with a linter (``ruff check``) and a formatter (``ruff format``).
The linter is a wrapper around flake8, pylint, and other linters,
and the formatter is a wrapper around black, isort, and other formatters.
The config for flake8 is located in setup.cfg. It specifies:
To run ruff without modifying your files: ::
* Set max line length to 120 chars
* Exclude ``.tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules``
$ ruff format --diff .
$ ruff check .
pylint
------
Ruff is capable of fixing most of the problems it encounters.
Be sure you commit first before running `ruff` so you can restore to a savepoint (and amend afterwards to prevent a double commit. : ::
To run pylint: ::
$ ruff format .
$ ruff check --fix .
# be careful with the --unsafe-fixes option, it can break your code
$ ruff check --fix --unsafe-fixes .
$ pylint <python files that you wish to lint>
The config for pylint is located in .pylintrc. It specifies:
* Use the pylint_django plugin. If using Celery, also use pylint_celery.
* Set max line length to 120 chars
* Disable linting messages for missing docstring and invalid name
* max-parents=13
pycodestyle
-----------
This is included in flake8's checks, but you can also run it separately to see a more detailed report: ::
$ pycodestyle <python files that you wish to lint>
The config for pycodestyle is located in setup.cfg. It specifies:
* Set max line length to 120 chars
* Exclude ``.tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules``
The config for ruff is located in pyproject.toml.
On of the most important option is `tool.ruff.lint.select`.
`select` determines which linters are run. In example, `DJ <https://docs.astral.sh/ruff/rules/#flake8-django-dj>`_ refers to flake8-django.
For a full list of available linters, see `https://docs.astral.sh/ruff/rules/ <https://docs.astral.sh/ruff/rules/>`_

View File

@ -4,9 +4,7 @@ binaryornot==0.4.4
# Code quality
# ------------------------------------------------------------------------------
black==24.2.0
isort==5.13.2
flake8==7.0.0
ruff==0.2.1
django-upgrade==1.16.0
djlint==1.34.1
pre-commit==3.6.1

View File

@ -180,28 +180,25 @@ def test_project_generation(cookies, context, context_override):
@pytest.mark.parametrize("context_override", SUPPORTED_COMBINATIONS, ids=_fixture_id)
def test_flake8_passes(cookies, context_override):
"""Generated project should pass flake8."""
def test_ruff_check_passes(cookies, context_override):
"""Generated project should pass ruff check."""
result = cookies.bake(extra_context=context_override)
try:
sh.flake8(_cwd=str(result.project_path))
sh.ruff("check", ".", _cwd=str(result.project_path))
except sh.ErrorReturnCode as e:
pytest.fail(e.stdout.decode())
@auto_fixable
@pytest.mark.parametrize("context_override", SUPPORTED_COMBINATIONS, ids=_fixture_id)
def test_black_passes(cookies, context_override):
"""Check whether generated project passes black style."""
def test_ruff_format_passes(cookies, context_override):
"""Check whether generated project passes ruff format."""
result = cookies.bake(extra_context=context_override)
try:
sh.black(
"--check",
"--diff",
"--exclude",
"migrations",
sh.ruff(
"format",
".",
_cwd=str(result.project_path),
)
@ -287,7 +284,7 @@ def test_travis_invokes_pytest(cookies, context, use_docker, expected_test_scrip
with open(f"{result.project_path}/.travis.yml") as travis_yml:
try:
yml = yaml.safe_load(travis_yml)["jobs"]["include"]
assert yml[0]["script"] == ["flake8"]
assert yml[0]["script"] == ["ruff check ."]
assert yml[1]["script"] == [expected_test_script]
except yaml.YAMLError as e:
pytest.fail(str(e))

View File

@ -37,22 +37,11 @@
"editor.codeActionsOnSave": {
"source.organizeImports": true
},
// Uncomment when fixed
// https://github.com/microsoft/vscode-remote-release/issues/8474
// "editor.defaultFormatter": "ms-python.black-formatter",
"formatting.blackPath": "/usr/local/bin/black",
"formatting.provider": "black",
"editor.defaultFormatter": "charliermarsh.ruff",
"languageServer": "Pylance",
// "linting.banditPath": "/usr/local/py-utils/bin/bandit",
"linting.enabled": true,
"linting.flake8Enabled": true,
"linting.flake8Path": "/usr/local/bin/flake8",
"linting.mypyEnabled": true,
"linting.mypyPath": "/usr/local/bin/mypy",
"linting.pycodestylePath": "/usr/local/bin/pycodestyle",
// "linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle",
"linting.pylintEnabled": true,
"linting.pylintPath": "/usr/local/bin/pylint"
}
},
// https://code.visualstudio.com/docs/remote/devcontainerjson-reference#_vs-code-specific-properties

View File

@ -33,26 +33,15 @@ repos:
- id: django-upgrade
args: ['--target-version', '4.2']
- repo: https://github.com/asottile/pyupgrade
rev: v3.15.0
# Run the Ruff linter.
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.2.1
hooks:
- id: pyupgrade
args: [--py311-plus]
- repo: https://github.com/psf/black
rev: 24.2.0
hooks:
- id: black
- repo: https://github.com/PyCQA/isort
rev: 5.13.2
hooks:
- id: isort
- repo: https://github.com/PyCQA/flake8
rev: 7.0.0
hooks:
- id: flake8
# Linter
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
# Formatter
- id: ruff-format
- repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.34.1

View File

@ -10,9 +10,9 @@ jobs:
include:
- name: "Linter"
before_script:
- pip install -q flake8
- pip install -q ruff
script:
- "flake8"
- ruff check .
- name: "Django Test"
{%- if cookiecutter.use_docker == 'y' %}
@ -24,7 +24,7 @@ jobs:
- docker compose -f local.yml run --rm django python manage.py migrate
- docker compose -f local.yml up -d
script:
- "docker compose -f local.yml run django pytest"
- docker compose -f local.yml run django pytest
after_failure:
- docker compose -f local.yml logs
{%- else %}
@ -41,5 +41,5 @@ jobs:
install:
- pip install -r requirements/local.txt
script:
- "pytest"
- pytest
{%- endif %}

View File

@ -3,7 +3,7 @@
{{ cookiecutter.description }}
[![Built with Cookiecutter Django](https://img.shields.io/badge/built%20with-Cookiecutter%20Django-ff69b4.svg?logo=cookiecutter)](https://github.com/cookiecutter/cookiecutter-django/)
[![Black code style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
{%- if cookiecutter.open_source_license != "Not open source" %}

View File

@ -1,12 +1,10 @@
from django.conf import settings
from rest_framework.routers import DefaultRouter, SimpleRouter
from rest_framework.routers import DefaultRouter
from rest_framework.routers import SimpleRouter
from {{ cookiecutter.project_slug }}.users.api.views import UserViewSet
if settings.DEBUG:
router = DefaultRouter()
else:
router = SimpleRouter()
router = DefaultRouter() if settings.DEBUG else SimpleRouter()
router.register("users", UserViewSet)

View File

@ -1,3 +1,4 @@
# ruff: noqa
"""
ASGI config for {{ cookiecutter.project_name }} project.
@ -29,7 +30,7 @@ django_application = get_asgi_application()
# application = HelloWorldApplication(application)
# Import websocket application here, so apps from django_application are loaded first
from config.websocket import websocket_application # noqa isort:skip
from config.websocket import websocket_application
async def application(scope, receive, send):
@ -38,4 +39,5 @@ async def application(scope, receive, send):
elif scope["type"] == "websocket":
await websocket_application(scope, receive, send)
else:
raise NotImplementedError(f"Unknown scope type {scope['type']}")
msg = f"Unknown scope type {scope['type']}"
raise NotImplementedError(msg)

View File

@ -1,3 +1,4 @@
# ruff: noqa: ERA001, E501
"""Base settings to build other settings files upon."""
from pathlib import Path
@ -136,7 +137,9 @@ PASSWORD_HASHERS = [
]
# https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
@ -209,7 +212,7 @@ TEMPLATES = [
"{{cookiecutter.project_slug}}.users.context_processors.allauth_settings",
],
},
}
},
]
# https://docs.djangoproject.com/en/dev/ref/settings/#form-renderer
@ -273,7 +276,7 @@ LOGGING = {
"level": "DEBUG",
"class": "logging.StreamHandler",
"formatter": "verbose",
}
},
},
"root": {"level": "INFO", "handlers": ["console"]},
}
@ -379,7 +382,7 @@ WEBPACK_LOADER = {
"STATS_FILE": BASE_DIR / "webpack-stats.json",
"POLL_INTERVAL": 0.1,
"IGNORE": [r".+\.hot-update.js", r".+\.map"],
}
},
}
{%- endif %}

View File

@ -1,4 +1,10 @@
from .base import * # noqa
# ruff: noqa: E501
from .base import * # noqa: F403
from .base import INSTALLED_APPS
from .base import MIDDLEWARE
{%- if cookiecutter.frontend_pipeline == 'Webpack' %}
from .base import WEBPACK_LOADER
{%- endif %}
from .base import env
# GENERAL
@ -11,7 +17,7 @@ SECRET_KEY = env(
default="!!!SET DJANGO_SECRET_KEY!!!",
)
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1"]
ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1"] # noqa: S104
# CACHES
# ------------------------------------------------------------------------------
@ -20,7 +26,7 @@ CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "",
}
},
}
# EMAIL
@ -37,7 +43,9 @@ EMAIL_HOST = "localhost"
EMAIL_PORT = 1025
{%- else -%}
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
EMAIL_BACKEND = env("DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend")
EMAIL_BACKEND = env(
"DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend",
)
{%- endif %}
{%- if cookiecutter.use_whitenoise == 'y' %}
@ -45,15 +53,15 @@ EMAIL_BACKEND = env("DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.c
# WhiteNoise
# ------------------------------------------------------------------------------
# http://whitenoise.evans.io/en/latest/django.html#using-whitenoise-in-development
INSTALLED_APPS = ["whitenoise.runserver_nostatic"] + INSTALLED_APPS # noqa: F405
INSTALLED_APPS = ["whitenoise.runserver_nostatic", *INSTALLED_APPS]
{% endif %}
# django-debug-toolbar
# ------------------------------------------------------------------------------
# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#prerequisites
INSTALLED_APPS += ["debug_toolbar"] # noqa: F405
INSTALLED_APPS += ["debug_toolbar"]
# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#middleware
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa: F405
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
# https://django-debug-toolbar.readthedocs.io/en/latest/configuration.html#debug-toolbar-config
DEBUG_TOOLBAR_CONFIG = {
"DISABLE_PANELS": ["debug_toolbar.panels.redirects.RedirectsPanel"],
@ -80,7 +88,7 @@ if env("USE_DOCKER") == "yes":
# django-extensions
# ------------------------------------------------------------------------------
# https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration
INSTALLED_APPS += ["django_extensions"] # noqa: F405
INSTALLED_APPS += ["django_extensions"]
{% if cookiecutter.use_celery == 'y' -%}
# Celery
@ -96,7 +104,7 @@ CELERY_TASK_EAGER_PROPAGATES = True
{%- if cookiecutter.frontend_pipeline == 'Webpack' %}
# django-webpack-loader
# ------------------------------------------------------------------------------
WEBPACK_LOADER["DEFAULT"]["CACHE"] = not DEBUG # noqa: F405
WEBPACK_LOADER["DEFAULT"]["CACHE"] = not DEBUG
{%- endif %}
# Your stuff...

View File

@ -1,3 +1,4 @@
# ruff: noqa: E501
{% if cookiecutter.use_sentry == 'y' -%}
import logging
@ -12,7 +13,12 @@ from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.redis import RedisIntegration
{% endif -%}
from .base import * # noqa
from .base import * # noqa: F403
from .base import DATABASES
from .base import INSTALLED_APPS
{%- if cookiecutter.use_drf == "y" %}
from .base import SPECTACULAR_SETTINGS
{%- endif %}
from .base import env
# GENERAL
@ -24,7 +30,7 @@ ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["{{ cookiecutter.domai
# DATABASES
# ------------------------------------------------------------------------------
DATABASES["default"]["CONN_MAX_AGE"] = env.int("CONN_MAX_AGE", default=60) # noqa: F405
DATABASES["default"]["CONN_MAX_AGE"] = env.int("CONN_MAX_AGE", default=60)
# CACHES
# ------------------------------------------------------------------------------
@ -38,7 +44,7 @@ CACHES = {
# https://github.com/jazzband/django-redis#memcached-exceptions-behavior
"IGNORE_EXCEPTIONS": True,
},
}
},
}
# SECURITY
@ -56,17 +62,23 @@ CSRF_COOKIE_SECURE = True
# TODO: set this to 60 seconds first and then to 518400 once you prove the former works
SECURE_HSTS_SECONDS = 60
# https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-include-subdomains
SECURE_HSTS_INCLUDE_SUBDOMAINS = env.bool("DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS", default=True)
SECURE_HSTS_INCLUDE_SUBDOMAINS = env.bool(
"DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS",
default=True,
)
# https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-preload
SECURE_HSTS_PRELOAD = env.bool("DJANGO_SECURE_HSTS_PRELOAD", default=True)
# https://docs.djangoproject.com/en/dev/ref/middleware/#x-content-type-options-nosniff
SECURE_CONTENT_TYPE_NOSNIFF = env.bool("DJANGO_SECURE_CONTENT_TYPE_NOSNIFF", default=True)
SECURE_CONTENT_TYPE_NOSNIFF = env.bool(
"DJANGO_SECURE_CONTENT_TYPE_NOSNIFF",
default=True,
)
{% if cookiecutter.cloud_provider != 'None' -%}
# STORAGES
# ------------------------------------------------------------------------------
# https://django-storages.readthedocs.io/en/latest/#installation
INSTALLED_APPS += ["storages"] # noqa: F405
INSTALLED_APPS += ["storages"]
{%- endif -%}
{% if cookiecutter.cloud_provider == 'AWS' %}
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings
@ -197,7 +209,7 @@ ADMIN_URL = env("DJANGO_ADMIN_URL")
# Anymail
# ------------------------------------------------------------------------------
# https://anymail.readthedocs.io/en/stable/installation/#installing-anymail
INSTALLED_APPS += ["anymail"] # noqa: F405
INSTALLED_APPS += ["anymail"]
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
# https://anymail.readthedocs.io/en/stable/installation/#anymail-settings-reference
{%- if cookiecutter.mail_service == 'Mailgun' %}
@ -273,7 +285,7 @@ COMPRESS_STORAGE = "compressor.storage.GzipCompressorFileStorage"
COMPRESS_STORAGE = STORAGES["staticfiles"]["BACKEND"]
{%- endif %}
# https://django-compressor.readthedocs.io/en/latest/settings/#django.conf.settings.COMPRESS_URL
COMPRESS_URL = STATIC_URL{% if cookiecutter.use_whitenoise == 'y' or cookiecutter.cloud_provider == 'None' %} # noqa: F405{% endif %}
COMPRESS_URL = STATIC_URL{% if cookiecutter.use_whitenoise == 'y' or cookiecutter.cloud_provider == 'None' %}{% endif %}
{%- if cookiecutter.use_whitenoise == 'y' %}
# https://django-compressor.readthedocs.io/en/latest/settings/#django.conf.settings.COMPRESS_OFFLINE
COMPRESS_OFFLINE = True # Offline compression is required when using Whitenoise
@ -291,7 +303,7 @@ COMPRESS_FILTERS = {
# Collectfast
# ------------------------------------------------------------------------------
# https://github.com/antonagestam/collectfast#installation
INSTALLED_APPS = ["collectfast"] + INSTALLED_APPS # noqa: F405
INSTALLED_APPS = ["collectfast", *INSTALLED_APPS]
{% endif %}
# LOGGING
# ------------------------------------------------------------------------------
@ -351,7 +363,7 @@ LOGGING = {
"level": "DEBUG",
"class": "logging.StreamHandler",
"formatter": "verbose",
}
},
},
"root": {"level": "INFO", "handlers": ["console"]},
"loggers": {
@ -403,7 +415,7 @@ sentry_sdk.init(
# django-rest-framework
# -------------------------------------------------------------------------------
# Tools that generate code samples can use SERVERS to point to the correct domain
SPECTACULAR_SETTINGS["SERVERS"] = [ # noqa: F405
SPECTACULAR_SETTINGS["SERVERS"] = [
{"url": "https://{{ cookiecutter.domain_name }}", "description": "Production server"},
]

View File

@ -2,7 +2,8 @@
With these settings, tests run faster.
"""
from .base import * # noqa
from .base import * # noqa: F403
from .base import TEMPLATES
from .base import env
# GENERAL
@ -27,7 +28,7 @@ EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
# DEBUGGING FOR TEMPLATES
# ------------------------------------------------------------------------------
TEMPLATES[0]["OPTIONS"]["debug"] = True # type: ignore # noqa: F405
TEMPLATES[0]["OPTIONS"]["debug"] = True # type: ignore[index]
# MEDIA
# ------------------------------------------------------------------------------

View File

@ -1,27 +1,37 @@
# ruff: noqa
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
{%- if cookiecutter.use_async == 'y' %}
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
{%- endif %}
from django.urls import include, path
from django.urls import include
from django.urls import path
from django.views import defaults as default_views
from django.views.generic import TemplateView
{%- if cookiecutter.use_drf == 'y' %}
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
from drf_spectacular.views import SpectacularAPIView
from drf_spectacular.views import SpectacularSwaggerView
from rest_framework.authtoken.views import obtain_auth_token
{%- endif %}
urlpatterns = [
path("", TemplateView.as_view(template_name="pages/home.html"), name="home"),
path("about/", TemplateView.as_view(template_name="pages/about.html"), name="about"),
path(
"about/",
TemplateView.as_view(template_name="pages/about.html"),
name="about",
),
# Django Admin, use {% raw %}{% url 'admin:index' %}{% endraw %}
path(settings.ADMIN_URL, admin.site.urls),
# User management
path("users/", include("{{ cookiecutter.project_slug }}.users.urls", namespace="users")),
path("accounts/", include("allauth.urls")),
# Your stuff: custom urls includes go here
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# ...
# Media files
*static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT),
]
{%- if cookiecutter.use_async == 'y' %}
if settings.DEBUG:
# Static file serving when using Gunicorn + Uvicorn for local web socket development

View File

@ -1,3 +1,4 @@
# ruff: noqa
"""
WSGI config for {{ cookiecutter.project_name }} project.

View File

@ -1,3 +1,4 @@
# ruff: noqa
# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full

View File

@ -1,4 +1,5 @@
#!/usr/bin/env python
# ruff: noqa
import os
import sys
from pathlib import Path
@ -13,7 +14,7 @@ if __name__ == "__main__":
# issue is really that Django is missing to avoid masking other
# exceptions on Python 2.
try:
import django # noqa
import django
except ImportError:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "

View File

@ -1,3 +1,4 @@
# ruff: noqa
import os
from collections.abc import Sequence
from pathlib import Path

View File

@ -16,25 +16,6 @@ include = ["{{cookiecutter.project_slug}}/**"]
omit = ["*/migrations/*", "*/tests/*"]
plugins = ["django_coverage_plugin"]
# ==== black ====
[tool.black]
line-length = 119
target-version = ['py311']
# ==== isort ====
[tool.isort]
profile = "black"
line_length = 119
known_first_party = [
"{{cookiecutter.project_slug}}",
"config",
]
skip = ["venv/"]
skip_glob = ["**/migrations/*.py"]
# ==== mypy ====
[tool.mypy]
python_version = "3.11"
@ -58,40 +39,6 @@ ignore_errors = true
[tool.django-stubs]
django_settings_module = "config.settings.test"
# ==== PyLint ====
[tool.pylint.MASTER]
load-plugins = [
"pylint_django",
{%- if cookiecutter.use_celery == "y" %}
"pylint_celery",
{%- endif %}
]
django-settings-module = "config.settings.local"
[tool.pylint.FORMAT]
max-line-length = 119
[tool.pylint."MESSAGES CONTROL"]
disable = [
"missing-docstring",
"invalid-name",
]
[tool.pylint.DESIGN]
max-parents = 13
[tool.pylint.TYPECHECK]
generated-members = [
"REQUEST",
"acl_users",
"aq_parent",
"[a-zA-Z]+_set{1,2}",
"save",
"delete",
]
# ==== djLint ====
[tool.djlint]
blank_line_after_tag = "load,extends"
@ -110,3 +57,112 @@ indent_size = 2
[tool.djlint.js]
indent_size = 2
[tool.ruff]
# Exclude a variety of commonly ignored directories.
exclude = [
".bzr",
".direnv",
".eggs",
".git",
".git-rewrite",
".hg",
".mypy_cache",
".nox",
".pants.d",
".pytype",
".ruff_cache",
".svn",
".tox",
".venv",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"venv",
"*/migrations/*.py",
"staticfiles/*"
]
# Same as Django: https://github.com/cookiecutter/cookiecutter-django/issues/4792.
line-length = 88
indent-width = 4
target-version = "py311"
[tool.ruff.lint]
select = [
"F",
"E",
"W",
"C90",
"I",
"N",
"UP",
"YTT",
# "ANN", # flake8-annotations: we should support this in the future but 100+ errors atm
"ASYNC",
"S",
"BLE",
"FBT",
"B",
"A",
"COM",
"C4",
"DTZ",
"T10",
"DJ",
"EM",
"EXE",
"FA",
'ISC',
"ICN",
"G",
'INP',
'PIE',
"T20",
'PYI',
'PT',
"Q",
"RSE",
"RET",
"SLF",
"SLOT",
"SIM",
"TID",
"TCH",
"INT",
# "ARG", # Unused function argument
"PTH",
"ERA",
"PD",
"PGH",
"PL",
"TRY",
"FLY",
# "NPY",
# "AIR",
"PERF",
# "FURB",
# "LOG",
"RUF"
]
ignore = [
"S101", # Use of assert detected https://docs.astral.sh/ruff/rules/assert/
"RUF012", # Mutable class attributes should be annotated with `typing.ClassVar`
"SIM102" # sometimes it's better to nest
]
# Allow fix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
unfixable = []
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
[tool.ruff.lint.isort]
force-single-line = true

View File

@ -28,15 +28,9 @@ sphinx-autobuild==2024.2.4 # https://github.com/GaretJax/sphinx-autobuild
# Code quality
# ------------------------------------------------------------------------------
flake8==7.0.0 # https://github.com/PyCQA/flake8
flake8-isort==6.1.1 # https://github.com/gforcada/flake8-isort
ruff==0.2.1
coverage==7.4.1 # https://github.com/nedbat/coveragepy
black==24.2.0 # https://github.com/psf/black
djlint==1.34.1 # https://github.com/Riverside-Healthcare/djLint
pylint-django==2.5.5 # https://github.com/PyCQA/pylint-django
{%- if cookiecutter.use_celery == 'y' %}
pylint-celery==0.3 # https://github.com/PyCQA/pylint-celery
{%- endif %}
pre-commit==3.6.1 # https://github.com/pre-commit/pre-commit
# Django

View File

@ -1,11 +0,0 @@
# flake8 and pycodestyle don't support pyproject.toml
# https://github.com/PyCQA/flake8/issues/234
# https://github.com/PyCQA/pycodestyle/issues/813
[flake8]
max-line-length = 119
exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules,venv,.venv
extend-ignore = E203
[pycodestyle]
max-line-length = 119
exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules,venv,.venv

View File

@ -1,2 +1,5 @@
__version__ = "{{ cookiecutter.version }}"
__version_info__ = tuple(int(num) if num.isdigit() else num for num in __version__.replace("-", ".", 1).split("."))
__version_info__ = tuple(
int(num) if num.isdigit() else num
for num in __version__.replace("-", ".", 1).split(".")
)

View File

@ -5,10 +5,10 @@ from {{ cookiecutter.project_slug }}.users.tests.factories import UserFactory
@pytest.fixture(autouse=True)
def media_storage(settings, tmpdir):
def _media_storage(settings, tmpdir) -> None:
settings.MEDIA_ROOT = tmpdir.strpath
@pytest.fixture
@pytest.fixture()
def user(db) -> User:
return UserFactory()

View File

@ -1,6 +1,7 @@
import django.contrib.sites.models
from django.contrib.sites.models import _simple_domain_name_validator
from django.db import migrations, models
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
@ -38,5 +39,5 @@ class Migration(migrations.Migration):
},
bases=(models.Model,),
managers=[("objects", django.contrib.sites.models.SiteManager())],
)
),
]

View File

@ -1,5 +1,6 @@
import django.contrib.sites.models
from django.db import migrations, models
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):

View File

@ -23,7 +23,7 @@ def _update_or_create_site_with_sequence(site_model, connection, domain, name):
# site is created.
# To avoid this, we need to manually update DB sequence and make sure it's
# greater than the maximum value.
max_id = site_model.objects.order_by('-id').first().id
max_id = site_model.objects.order_by("-id").first().id
with connection.cursor() as cursor:
cursor.execute("SELECT last_value from django_site_id_seq")
(current_id,) = cursor.fetchone()

View File

@ -5,10 +5,11 @@ import typing
from allauth.account.adapter import DefaultAccountAdapter
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from django.conf import settings
from django.http import HttpRequest
if typing.TYPE_CHECKING:
from allauth.socialaccount.models import SocialLogin
from django.http import HttpRequest
from {{cookiecutter.project_slug}}.users.models import User
@ -18,10 +19,19 @@ class AccountAdapter(DefaultAccountAdapter):
class SocialAccountAdapter(DefaultSocialAccountAdapter):
def is_open_for_signup(self, request: HttpRequest, sociallogin: SocialLogin) -> bool:
def is_open_for_signup(
self,
request: HttpRequest,
sociallogin: SocialLogin,
) -> bool:
return getattr(settings, "ACCOUNT_ALLOW_REGISTRATION", True)
def populate_user(self, request: HttpRequest, sociallogin: SocialLogin, data: dict[str, typing.Any]) -> User:
def populate_user(
self,
request: HttpRequest,
sociallogin: SocialLogin,
data: dict[str, typing.Any],
) -> User:
"""
Populates user information from social provider info.

View File

@ -1,10 +1,12 @@
from django.conf import settings
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.contrib.auth import get_user_model, decorators
from django.contrib.auth import decorators
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _
from {{ cookiecutter.project_slug }}.users.forms import UserAdminChangeForm, UserAdminCreationForm
from {{ cookiecutter.project_slug }}.users.forms import UserAdminChangeForm
from {{ cookiecutter.project_slug }}.users.forms import UserAdminCreationForm
User = get_user_model()

View File

@ -3,7 +3,6 @@ from rest_framework import serializers
from {{ cookiecutter.project_slug }}.users.models import User as UserType
User = get_user_model()

View File

@ -1,7 +1,9 @@
from django.contrib.auth import get_user_model
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.mixins import ListModelMixin, RetrieveModelMixin, UpdateModelMixin
from rest_framework.mixins import ListModelMixin
from rest_framework.mixins import RetrieveModelMixin
from rest_framework.mixins import UpdateModelMixin
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet

View File

@ -1,3 +1,5 @@
import contextlib
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
@ -7,7 +9,5 @@ class UsersConfig(AppConfig):
verbose_name = _("Users")
def ready(self):
try:
with contextlib.suppress(ImportError):
import {{ cookiecutter.project_slug }}.users.signals # noqa: F401
except ImportError:
pass

View File

@ -15,7 +15,8 @@ class UserManager(DjangoUserManager["User"]):
Create and save a user with the given email and password.
"""
if not email:
raise ValueError("The given email must be set")
msg = "The given email must be set"
raise ValueError(msg)
email = self.normalize_email(email)
user = self.model(email=email, **extra_fields)
user.password = make_password(password)
@ -32,8 +33,10 @@ class UserManager(DjangoUserManager["User"]):
extra_fields.setdefault("is_superuser", True)
if extra_fields.get("is_staff") is not True:
raise ValueError("Superuser must have is_staff=True.")
msg = "Superuser must have is_staff=True."
raise ValueError(msg)
if extra_fields.get("is_superuser") is not True:
raise ValueError("Superuser must have is_superuser=True.")
msg = "Superuser must have is_superuser=True."
raise ValueError(msg)
return self._create_user(email, password, **extra_fields)

View File

@ -1,7 +1,8 @@
import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
import django.utils.timezone
from django.db import migrations
from django.db import models
import {{cookiecutter.project_slug}}.users.models
@ -31,7 +32,7 @@ class Migration(migrations.Migration):
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
blank=True, null=True, verbose_name="last login",
),
),
(
@ -61,14 +62,14 @@ class Migration(migrations.Migration):
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
blank=True, max_length=254, verbose_name="email address",
),
),
{%- else %}
(
"email",
models.EmailField(
unique=True, max_length=254, verbose_name="email address"
unique=True, max_length=254, verbose_name="email address",
),
),
{%- endif %}
@ -91,13 +92,13 @@ class Migration(migrations.Migration):
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
default=django.utils.timezone.now, verbose_name="date joined",
),
),
(
"name",
models.CharField(
blank=True, max_length=255, verbose_name="Name of User"
blank=True, max_length=255, verbose_name="Name of User",
),
),
(

View File

@ -1,9 +1,12 @@
{%- if cookiecutter.username_type == "email" %}
from typing import ClassVar
{%- endif %}
{% endif -%}
from django.contrib.auth.models import AbstractUser
from django.db.models import CharField{% if cookiecutter.username_type == "email" %}, EmailField{% endif %}
from django.db.models import CharField
{%- if cookiecutter.username_type == "email" %}
from django.db.models import EmailField
{%- endif %}
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
{%- if cookiecutter.username_type == "email" %}
@ -21,11 +24,11 @@ class User(AbstractUser):
# First and last name do not cover name patterns around the globe
name = CharField(_("Name of User"), blank=True, max_length=255)
first_name = None # type: ignore
last_name = None # type: ignore
first_name = None # type: ignore[assignment]
last_name = None # type: ignore[assignment]
{%- if cookiecutter.username_type == "email" %}
email = EmailField(_("email address"), unique=True)
username = None # type: ignore
username = None # type: ignore[assignment]
USERNAME_FIELD = "email"
REQUIRED_FIELDS = []

View File

@ -2,7 +2,8 @@ from collections.abc import Sequence
from typing import Any
from django.contrib.auth import get_user_model
from factory import Faker, post_generation
from factory import Faker
from factory import post_generation
from factory.django import DjangoModelFactory
@ -14,7 +15,7 @@ class UserFactory(DjangoModelFactory):
name = Faker("name")
@post_generation
def password(self, create: bool, extracted: Sequence[Any], **kwargs):
def password(self, create: bool, extracted: Sequence[Any], **kwargs): # noqa: FBT001
password = (
extracted
if extracted

View File

@ -1,3 +1,5 @@
import contextlib
from http import HTTPStatus
from importlib import reload
import pytest
@ -13,17 +15,17 @@ class TestUserAdmin:
def test_changelist(self, admin_client):
url = reverse("admin:users_user_changelist")
response = admin_client.get(url)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
def test_search(self, admin_client):
url = reverse("admin:users_user_changelist")
response = admin_client.get(url, data={"q": "test"})
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
def test_add(self, admin_client):
url = reverse("admin:users_user_add")
response = admin_client.get(url)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
response = admin_client.post(
url,
@ -37,7 +39,7 @@ class TestUserAdmin:
"password2": "My_R@ndom-P@ssw0rd",
},
)
assert response.status_code == 302
assert response.status_code == HTTPStatus.FOUND
{%- if cookiecutter.username_type == "email" %}
assert User.objects.filter(email="new-admin@example.com").exists()
{%- else %}
@ -52,21 +54,19 @@ class TestUserAdmin:
{%- endif %}
url = reverse("admin:users_user_change", kwargs={"object_id": user.pk})
response = admin_client.get(url)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
@pytest.fixture
def force_allauth(self, settings):
@pytest.fixture()
def _force_allauth(self, settings):
settings.DJANGO_ADMIN_FORCE_ALLAUTH = True
# Reload the admin module to apply the setting change
import {{ cookiecutter.project_slug }}.users.admin as users_admin # pylint: disable=import-outside-toplevel
import {{ cookiecutter.project_slug }}.users.admin as users_admin
try:
with contextlib.suppress(admin.sites.AlreadyRegistered):
reload(users_admin)
except admin.sites.AlreadyRegistered:
pass
@pytest.mark.django_db
@pytest.mark.usefixtures("force_allauth")
@pytest.mark.django_db()
@pytest.mark.usefixtures("_force_allauth")
def test_allauth_login(self, rf, settings):
request = rf.get("/fake-url")
request.user = AnonymousUser()

View File

@ -1,14 +1,20 @@
from django.urls import resolve, reverse
from django.urls import resolve
from django.urls import reverse
from {{ cookiecutter.project_slug }}.users.models import User
def test_user_detail(user: User):
{%- if cookiecutter.username_type == "email" %}
assert reverse("api:user-detail", kwargs={"pk": user.pk}) == f"/api/users/{user.pk}/"
assert (
reverse("api:user-detail", kwargs={"pk": user.pk}) == f"/api/users/{user.pk}/"
)
assert resolve(f"/api/users/{user.pk}/").view_name == "api:user-detail"
{%- else %}
assert reverse("api:user-detail", kwargs={"username": user.username}) == f"/api/users/{user.username}/"
assert (
reverse("api:user-detail", kwargs={"username": user.username})
== f"/api/users/{user.username}/"
)
assert resolve(f"/api/users/{user.username}/").view_name == "api:user-detail"
{%- endif %}

View File

@ -6,7 +6,7 @@ from {{ cookiecutter.project_slug }}.users.models import User
class TestUserViewSet:
@pytest.fixture
@pytest.fixture()
def api_rf(self) -> APIRequestFactory:
return APIRequestFactory()
@ -26,7 +26,7 @@ class TestUserViewSet:
view.request = request
response = view.me(request) # type: ignore
response = view.me(request) # type: ignore[call-arg, arg-type, misc]
assert response.data == {
{%- if cookiecutter.username_type == "email" %}

View File

@ -30,7 +30,7 @@ class TestUserAdminCreationForm:
{%- endif %}
"password1": user.password,
"password2": user.password,
}
},
)
assert not form.is_valid()

View File

@ -6,12 +6,12 @@ from django.core.management import call_command
from {{ cookiecutter.project_slug }}.users.models import User
@pytest.mark.django_db
@pytest.mark.django_db()
class TestUserManager:
def test_create_user(self):
user = User.objects.create_user(
email="john@example.com",
password="something-r@nd0m!",
password="something-r@nd0m!", # noqa: S106
)
assert user.email == "john@example.com"
assert not user.is_staff
@ -22,7 +22,7 @@ class TestUserManager:
def test_create_superuser(self):
user = User.objects.create_superuser(
email="admin@example.com",
password="something-r@nd0m!",
password="something-r@nd0m!", # noqa: S106
)
assert user.email == "admin@example.com"
assert user.is_staff
@ -32,12 +32,12 @@ class TestUserManager:
def test_create_superuser_username_ignored(self):
user = User.objects.create_superuser(
email="test@example.com",
password="something-r@nd0m!",
password="something-r@nd0m!", # noqa: S106
)
assert user.username is None
@pytest.mark.django_db
@pytest.mark.django_db()
def test_createsuperuser_command():
"""Ensure createsuperuser command works with our custom manager."""
out = StringIO()

View File

@ -1,3 +1,5 @@
from http import HTTPStatus
import pytest
from django.urls import reverse
@ -5,17 +7,17 @@ from django.urls import reverse
def test_swagger_accessible_by_admin(admin_client):
url = reverse("api-docs")
response = admin_client.get(url)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
@pytest.mark.django_db
@pytest.mark.django_db()
def test_swagger_ui_not_accessible_by_normal_user(client):
url = reverse("api-docs")
response = client.get(url)
assert response.status_code == 403
assert response.status_code == HTTPStatus.FORBIDDEN
def test_api_schema_generated_successfully(admin_client):
url = reverse("api-schema")
response = admin_client.get(url)
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK

View File

@ -9,8 +9,9 @@ pytestmark = pytest.mark.django_db
def test_user_count(settings):
"""A basic test to execute the get_users_count Celery task."""
UserFactory.create_batch(3)
batch_size = 3
UserFactory.create_batch(batch_size)
settings.CELERY_TASK_ALWAYS_EAGER = True
task_result = get_users_count.delay()
assert isinstance(task_result, EagerResult)
assert task_result.result == 3
assert task_result.result == batch_size

View File

@ -1,4 +1,5 @@
from django.urls import resolve, reverse
from django.urls import resolve
from django.urls import reverse
from {{ cookiecutter.project_slug }}.users.models import User
@ -8,7 +9,10 @@ def test_detail(user: User):
assert reverse("users:detail", kwargs={"pk": user.pk}) == f"/users/{user.pk}/"
assert resolve(f"/users/{user.pk}/").view_name == "users:detail"
{%- else %}
assert reverse("users:detail", kwargs={"username": user.username}) == f"/users/{user.username}/"
assert (
reverse("users:detail", kwargs={"username": user.username})
== f"/users/{user.username}/"
)
assert resolve(f"/users/{user.username}/").view_name == "users:detail"
{%- endif %}

View File

@ -1,10 +1,13 @@
from http import HTTPStatus
import pytest
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.models import AnonymousUser
from django.contrib.messages.middleware import MessageMiddleware
from django.contrib.sessions.middleware import SessionMiddleware
from django.http import HttpRequest, HttpResponseRedirect
from django.http import HttpRequest
from django.http import HttpResponseRedirect
from django.test import RequestFactory
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@ -12,11 +15,9 @@ from django.utils.translation import gettext_lazy as _
from {{ cookiecutter.project_slug }}.users.forms import UserAdminChangeForm
from {{ cookiecutter.project_slug }}.users.models import User
from {{ cookiecutter.project_slug }}.users.tests.factories import UserFactory
from {{ cookiecutter.project_slug }}.users.views import (
UserRedirectView,
UserUpdateView,
user_detail_view,
)
from {{ cookiecutter.project_slug }}.users.views import UserRedirectView
from {{ cookiecutter.project_slug }}.users.views import UserUpdateView
from {{ cookiecutter.project_slug }}.users.views import user_detail_view
pytestmark = pytest.mark.django_db
@ -102,7 +103,7 @@ class TestUserDetailView:
response = user_detail_view(request, username=user.username)
{%- endif %}
assert response.status_code == 200
assert response.status_code == HTTPStatus.OK
def test_not_authenticated(self, user: User, rf: RequestFactory):
request = rf.get("/fake-url/")
@ -116,5 +117,5 @@ class TestUserDetailView:
login_url = reverse(settings.LOGIN_URL)
assert isinstance(response, HttpResponseRedirect)
assert response.status_code == 302
assert response.status_code == HTTPStatus.FOUND
assert response.url == f"{login_url}?next=/fake-url/"

View File

@ -1,10 +1,8 @@
from django.urls import path
from {{ cookiecutter.project_slug }}.users.views import (
user_detail_view,
user_redirect_view,
user_update_view,
)
from {{ cookiecutter.project_slug }}.users.views import user_detail_view
from {{ cookiecutter.project_slug }}.users.views import user_redirect_view
from {{ cookiecutter.project_slug }}.users.views import user_update_view
app_name = "users"
urlpatterns = [

View File

@ -3,7 +3,9 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, RedirectView, UpdateView
from django.views.generic import DetailView
from django.views.generic import RedirectView
from django.views.generic import UpdateView
User = get_user_model()
@ -28,7 +30,8 @@ class UserUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
success_message = _("Information successfully updated")
def get_success_url(self):
assert self.request.user.is_authenticated # for mypy to know that the user is authenticated
# for mypy to know that the user is authenticated
assert self.request.user.is_authenticated
return self.request.user.get_absolute_url()
def get_object(self):