From a12df5ae297a821aacc0f6b6afecc073c5f70687 Mon Sep 17 00:00:00 2001 From: Kenneth Love <11908+kennethlove@users.noreply.github.com> Date: Thu, 18 Jan 2024 16:02:07 -0800 Subject: [PATCH 1/2] Hints (#2) I think I'm ready to release. --- .devcontainer/devcontainer.json | 28 ++++--- .github/workflows/docs.yml | 42 ---------- .github/workflows/testing.yml | 16 +++- .github/workflows/typing.yml | 15 ++++ .gitignore | 1 + README.md | 2 + docs/contribution_guide.md | 48 ++++------- docs/index.md | 16 +++- docs/mixins/misc.md | 23 ------ docs/mixins/rest_framework.md | 2 +- mkdocs.yml | 3 + pyproject.toml | 91 ++++++++++----------- src/brackets/exceptions.py | 6 ++ src/brackets/mixins/__init__.py | 18 ++--- src/brackets/mixins/__init__.pyi | 92 +++++++++++---------- src/brackets/mixins/access.py | 78 +++++++++--------- src/brackets/mixins/access.pyi | 11 +-- src/brackets/mixins/form_views.py | 107 +++++++++++++------------ src/brackets/mixins/form_views.pyi | 23 +++--- src/brackets/mixins/forms.py | 11 ++- src/brackets/mixins/forms.pyi | 6 +- src/brackets/mixins/http.py | 87 +++++++++++++------- src/brackets/mixins/http.pyi | 40 +++++---- src/brackets/mixins/misc.py | 36 --------- src/brackets/mixins/misc.pyi | 7 -- src/brackets/mixins/queries.py | 61 +++++++------- src/brackets/mixins/queries.pyi | 17 ++-- src/brackets/mixins/redirects.py | 26 +++--- src/brackets/mixins/redirects.pyi | 6 +- src/brackets/mixins/rest_framework.py | 32 ++++---- src/brackets/mixins/rest_framework.pyi | 8 +- src/brackets/py.typed | 0 tests/conftest.py | 50 +++++++----- tests/mixins/test_access.py | 25 +++--- tests/mixins/test_form_views.py | 31 ++++--- tests/mixins/test_http.py | 59 +++++++++++--- tests/mixins/test_misc.py | 54 ------------- tests/mixins/test_queries.py | 12 ++- tests/mixins/test_redirects.py | 25 ++---- tests/mixins/test_rest_framework.py | 58 +++++++++++--- tests/project/forms.py | 12 +-- tests/project/helpers.py | 62 ++------------ tests/project/models.py | 12 +-- tests/project/settings.py | 21 +++-- tests/project/urls.py | 3 +- tox.ini | 18 +++++ 46 files changed, 692 insertions(+), 709 deletions(-) delete mode 100644 .github/workflows/docs.yml create mode 100644 .github/workflows/typing.yml delete mode 100644 docs/mixins/misc.md create mode 100644 src/brackets/exceptions.py delete mode 100644 src/brackets/mixins/misc.py delete mode 100644 src/brackets/mixins/misc.pyi create mode 100644 src/brackets/py.typed delete mode 100644 tests/mixins/test_misc.py create mode 100644 tox.ini diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d1dc5ef..7cdc25e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,24 +5,34 @@ // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile "image": "mcr.microsoft.com/devcontainers/python:0-3.11", "features": { - "ghcr.io/devcontainers/features/python:1": { - "installTools": false, - "installJupyterlab": false, - "version": "3.11" + "ghcr.io/eliises/devcontainer-features/devcontainers-cli:1": { + "version": "latest", + "nodeVersion": "latest" }, - "ghcr.io/eliises/devcontainer-features/devcontainers-cli:1": {}, "ghcr.io/wxw-matt/devcontainer-features/command_runner:0": {}, - "ghcr.io/wxw-matt/devcontainer-features/script_runner:0": {} + "ghcr.io/wxw-matt/devcontainer-features/script_runner:0": {}, + "ghcr.io/devcontainers-contrib/features/pipx-package:1": { + "includeDeps": true, + "package": "black", + "version": "latest", + "injections": "pylint pytest", + "interpreter": "python3" + }, + "ghcr.io/kennethlove/multiple-pythons/multiple-pythons:1": { + "versions": "3.12 3.11 3.10" + } }, + // "postCreateCommand": "", "postStartCommand": "pip install -e .[development,testing]", "customizations": { "vscode": { "extensions": [ + "charliermarsh.ruff", "EditorConfig.EditorConfig", - "ms-python.black-formatter", + "matangover.mypy", + "ms-python.mypy-type-checker", "ms-python.python", - "ms-python.vscode-pylance", - "charliermarsh.ruff" + "ms-python.vscode-pylance" ], "settings": { "[python]": { diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index c02ea1c..0000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Publish docs via GitHub Pages -on: - push: - branches: - - main -jobs: - install-dependencies: - name: Install dependencies - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: pdm-project/setup-pdm@v3 - - - name: Install Dependencies - run: pdm install -dG documentation - build-docs: - name: Build docs - needs: install-dependencies - runs-on: ubuntu-latest - steps: - - name: Build Docs - uses: moonpathbg/mkdocs_builder@latest - - - name: Upload GitHub Pages artifact - uses: actions/upload-pages-artifact@v1.0.9 - - deploy-docs: - needs: build-docs - - permissions: - pages: write - id-token: write - - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - - runs-on: ubuntu-latest - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v2 diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index d93053a..4b46c3f 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -3,13 +3,21 @@ on: [pull_request, workflow_dispatch] jobs: test: runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + django-version: ["4.2", "5.0"] steps: - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 with: - python-version: '3.11' - - run: pip install django django-rest-framework pytest pytest-django pytest-lazy-fixture + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + pip install django~=${{ matrix.django-version }} + pip install django-rest-framework pytest pytest-django pytest-lazy-fixture pytest-cov - name: Run pytest env: PYTHONPATH: tests/project/:src/ - run: pytest + run: pytest --cov --cov-fail-under=100 diff --git a/.github/workflows/typing.yml b/.github/workflows/typing.yml new file mode 100644 index 0000000..23975f3 --- /dev/null +++ b/.github/workflows/typing.yml @@ -0,0 +1,15 @@ +name: django-brackets-typing +on: [pull_request, workflow_dispatch] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.11' + - run: pip install mypy django-stubs[compatible-mypy] djangorestframework-stubs[compatible-mypy] + - name: Run mypy + env: + PYTHONPATH: tests/project/:src/ + run: mypy --install-types --non-interactive src diff --git a/.gitignore b/.gitignore index ea14831..0786166 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ coverage.xml .vscode/ lcov.* .pdm-python +_site/ diff --git a/README.md b/README.md index 42a9ee2..26a92ac 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # django-brackets +[Official Documentation](https://django-brackets.readthedocs.io) + `django-brackets` is a small collection of mixins for your class-based views' needs. Heavily based on [`django-braces`], `brackets` aims to be a simpler API and lighter tool set than `braces` was. diff --git a/docs/contribution_guide.md b/docs/contribution_guide.md index e369ae2..5611649 100644 --- a/docs/contribution_guide.md +++ b/docs/contribution_guide.md @@ -1,51 +1,35 @@ --- hide: -- navigation -- toc + - navigation --- - # Contributing -First of all, thank you for wanting to make `django-brackets` better! We love -getting input and suggestions from the community. Secondly, we just want to -put out a few ground rules for contributing so that we can get your pull -requests in sooner and cause fewer headaches all around. +First of all, thank you for wanting to make `django-brackets` better! We love getting input and suggestions from the community. Secondly, we just want to put out a few ground rules for contributing so that we can get your pull requests in sooner and cause fewer headaches all around. + +## Installation + +When you want to install `django-brackets` for local development, first clone the project from GitHub. Secondly, install it as an editable package and install the testing and development dependencies: `pip install -e django-brackets[testing,development]`. You can test the project via `pytest` and check types with `mypy src`. ## Code of Conduct -Any communication around `django-brackets`, any contribution, any issue, -is under the guidelines of the -[Django code of conduct](https://www.djangoproject.com/conduct/). We don't -allow any form of hate or discrimination in this project. +Any communication around `django-brackets`, any contribution, any issue, is under the guidelines of the [Django code of conduct](https://www.djangoproject.com/conduct/). We don't allow any form of hate or discrimination in this project. -If you object to the code of conduct, you are not licensed to use -this software. +If you object to the code of conduct, you are not licensed to use this software. ## Code Style -All contributions require certain formatting and checks before they can -be accepted. Your PR should: -- be formatted with `black` with an allowed line length of 99. -- have docstrings for all files, classes, and functions. Use `interrogate` - to verify your work. -- be well-typed. We use `mypy` for static type checking. Run `mypy src` - to check your types. +All contributions require certain formatting and checks before they can be accepted. Your PR should: +- be formatted with `ruff` with an allowed line length of 88. +- have docstrings for all files, classes, and functions. Use `interrogate` to verify your work. +- be well-typed. We use `mypy` for static type checking. Run `mypy src` to check your types. +- maintain or increase code coverage. ## Tests -Your PR should also be well-tested. We use the `pytest` testing framework -and make heavy use of fixtures over mocks. We aim for 100% test coverage -but we also recognize that 100% is a magic number and won't prevent all -bugs. Still, makes refactors easier! +Your PR should also be well-tested. We use the `pytest` testing framework and make heavy use of fixtures over mocks. We aim for 100% test coverage but we also recognize that 100% is a magic number and won't prevent all bugs. Still, makes refactors easier! -We test `django-brackets` against the newest stable version of Python and -the latest Long Term Support (LTS) release of Django. Other versions of -Python and Django may work but are not tested against and, thus, unsupported. +We test `django-brackets` against the newest stable version of Python and the latest Long Term Support (LTS) release of Django. Other versions of Python and Django may work but are not tested against and, thus, unsupported. ## Documentation -Documentation is one of the most important parts of any project. If you -don't know how to use it, you probably won't. All PRs should come with -corresponding documentation updates. New mixins should come with a usage -example and documentation explaining the concept. We use Mkdocs for our -documentation needs. +Documentation is one of the most important parts of any project. If you don't know how to use it, you probably won't. All PRs should come with corresponding documentation updates. New mixins should come with a usage example and documentation explaining the concept. We use Mkdocs for our documentation needs. diff --git a/docs/index.md b/docs/index.md index f0b613b..9187866 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,12 +7,26 @@ hide: # django-brackets ## Mixins to make Django's Class-based Views Simpler and Neater -`django-brackets` is a library of mixins to make using class-based views easier. Yes, it's a lot like [`django-braces`](https://github.com/brack3t/django-braces); it should, we wrote them both. In fact, most of `django-brackets` comes from a rewrite of `django-braces` that was just too big and breaking. +`django-brackets` is a library of mixins to make using class-based views easier. Yes, it's a lot like [`django-braces`](https://github.com/brack3t/django-braces); it should be, we wrote them both. In fact, most of `django-brackets` comes from a rewrite of `django-braces` that was just too big and breaking. Use these mixins as inspiration for your own, as well! The `PassesTestMixin` is used, for example, to build all of the Access mixins. You can use it to make your own mixins that require a request to pass some arbitrary test! As you'll see in our [contribution guide], we also love contributions. Send your mixins in today! +## Installation and usage + +You'll need to install `django-brackets` via `pip`: `pip install django-brackets`. You do _not_ need to add `brackets` to your `INSTALLED_APPS` in order to use the mixins. In a `views.py` where you need a mixin, you'll import them like: `from brackets import mixins`. + +Mixins should be first in your inheritance tree, view classes last. For example: + +```py +from django.views import generic +from brackets import mixins + +class UserList(mixins.LoginRequiredMixin, generic.ListView): + ... +``` + # Mixins ## HTTP- and request-related mixins diff --git a/docs/mixins/misc.md b/docs/mixins/misc.md deleted file mode 100644 index 82c9562..0000000 --- a/docs/mixins/misc.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -hide: -- navigation ---- - -# Miscellaneous mixins - -Sometimes mixins just don't have a good home. - -## StaticContextMixin - -This mixin allows you to set static values which will always be injected -into the view's `context` that's used to render templates. - -```py -from brackets.mixins import StaticContextMixin - -class WelcomePage(StaticContextMixin, TemplateView): - static_context = {"greeting": "Go away"} -``` - -If you'd like to provide your static context in a dynamic way...you can -so by overriding the `get_static_context` method. diff --git a/docs/mixins/rest_framework.md b/docs/mixins/rest_framework.md index a8d5c81..9aad97c 100644 --- a/docs/mixins/rest_framework.md +++ b/docs/mixins/rest_framework.md @@ -26,4 +26,4 @@ class SerialCereal(MultipleSerializersMixin, ViewSet): } ``` -[form_views.MultipleFormView]: mixins/form_views.md#multipleformsmixin +[form_views.MultipleFormView]: form_views.md#multipleformsmixin diff --git a/mkdocs.yml b/mkdocs.yml index fc50e84..f28cfbc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,6 +5,8 @@ markdown_extensions: - pymdownx.inlinehilite - pymdownx.superfences nav: + - Home: + - index.md - Access: - mixins/access.md - Django REST Framework: @@ -23,6 +25,7 @@ nav: - mixins/redirects.md - Contribution: - contribution_guide.md + - Contributors: - contributors.md plugins: - search diff --git a/pyproject.toml b/pyproject.toml index ddf3b3a..8f11d50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,12 +26,11 @@ description = "Mixins to make class-based generic views simpler and neater." license = { file = "LICENSE.txt" } name = "django-brackets" readme = "README.md" -requires-python = ">=3.11" -version = "0.1.0" +requires-python = ">=3.10" +version = "2023" [project.optional-dependencies] development = [ - "black", "django-stubs[compatible-mypy]", "djangorestframework-stubs[compatible-mypy]", "interrogate", @@ -52,6 +51,7 @@ testing = [ "pytest-lazy-fixture", "pytest-randomly", "pytest-xdist", + "tox", ] [project.urls] @@ -91,6 +91,7 @@ django_settings_module = "tests.project.settings" name = "brackets" [tool.interrogate] +color = true exclude = ["build", "conftest.py", "docs", "setup.py"] fail-under = 75 ignore-init-method = true @@ -103,40 +104,52 @@ ignore-private = false ignore-property-decorators = false ignore-regex = ["^get$", "^mock_.*"] ignore-semiprivate = false -# possible values: 0 (minimal output), 1 (-v), 2 (-vv) -color = true omit-covered-files = true quiet = false verbose = 1 [tool.mypy] -check_untyped_defs = false +allow_redefinition = false +check_untyped_defs = true color_output = true disallow_untyped_calls = false disallow_untyped_decorators = false disallow_untyped_defs = false error_summary = true -exclude = [".venv", "build", "dist", "docs", "tests"] -implicit_optional = true -no_implicit_optional = false +exclude = [".venv", "build", "dist", "docs", "tests/*"] +ignore_errors = false +ignore_missing_imports = true +implicit_reexport = false +local_partial_types = true +no_implicit_optional = true plugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"] pretty = true show_column_numbers = true -show_error_context = false +show_error_context = true strict = true -warn_redundant_casts = false +strict_equality = true +strict_optional = true +warn_no_return = true +warn_redundant_casts = true warn_return_any = true +warn_unreachable = true +warn_unused_configs = true warn_unused_ignores = true [tool.pyright] -exclude = [".venv", "build", "dist", "docs", "tests"] +exclude = ["build", "dist", "docs", "tests"] +ignore = ["tests"] include = ["src"] pythonVerion = "3.11" -reportMissingImports = true -reportMissingTypeStubs = false +reportMissingImports = "information" +reportMissingTypeStubs = "information" +reportUnknownMemberType = "information" +root = ["src"] +strict = ["src"] +typeCheckingMode = "strict" +useLibraryCodeForTypes = true [tool.pytest.ini_options] -# ini_options DJANGO_SETTINGS_MODULE = "tests.project.settings" django_find_project = false markers = ["mixin", "mixin_view_factory"] @@ -176,50 +189,28 @@ select = [ ] # ignore -ignore = ["ANN101", "F403"] +ignore = ["ANN101", "COM812", "D203", "D211", "D213", "F403"] # Allow autofix for all enabled rules (when `--fix`) is provided. -fixable = [ - "ANN", - "B", - "BLE", - "COM", - "D", - "DJ", - "E", - "EM", - "ERA", - "F", - "FBT", - "G", - "I", - "INP", - "N", - "PIE", - "PT", - "PYI", - "Q", - "RET", - "RSE", - "RUF", - "S", - "SIM", - "T20", - "TCH", - "TRY", -] - +fixable = ["ALL"] unfixable = [] extend-exclude = ["docs"] ignore-init-module-imports = true -# Assume Python 3.11. src = ["src", "tests"] +# Assume Python 3.11. target-version = "py311" -# format = "json" -format = "pylint" +indent-width = 4 +line-length = 88 +output-format = "pylint" + +[tool.ruff.format] +indent-style = "space" +line-ending = "auto" +quote-style = "double" +skip-magic-trailing-comma = false [tool.ruff.per-file-ignores] "src/brackets/*.pyi" = [ @@ -234,7 +225,7 @@ format = "pylint" "ANN201", # Missing return type annotation for public function "ANN202", # Missing return type annotation for private function "FBT001", # Positional Boolean - "S101", # Missing return type annotation + "S101", # assert used ] [tool.ruff.isort] diff --git a/src/brackets/exceptions.py b/src/brackets/exceptions.py new file mode 100644 index 0000000..f1f3f4f --- /dev/null +++ b/src/brackets/exceptions.py @@ -0,0 +1,6 @@ +"""Exceptions for django-brackets mixins.""" +from django.core.exceptions import ImproperlyConfigured + + +class BracketsConfigurationError(ImproperlyConfigured): + """Raised when a django-brackets mixin is not configured correctly.""" diff --git a/src/brackets/mixins/__init__.py b/src/brackets/mixins/__init__.py index 650569d..e8274f0 100644 --- a/src/brackets/mixins/__init__.py +++ b/src/brackets/mixins/__init__.py @@ -1,11 +1,9 @@ -from .access import * # noqa: F401, F403 -from .form_views import * # noqa: F401, F403 -from .forms import * # noqa: F401, F403 -from .http import * # noqa: F401, F403 -from .misc import * # noqa: F401, F403 -from .queries import * # noqa: F401, F403 -from .redirects import * # noqa: F401, F403 -from .rest_framework import * # noqa: F401, F403 +"""django-brackets mixins package.""" -# F401: module imported but unused -# F403: 'from module import *' used; unable to detect undefined names +from .access import * +from .form_views import * +from .forms import * +from .http import * +from .queries import * +from .redirects import * +from .rest_framework import * diff --git a/src/brackets/mixins/__init__.pyi b/src/brackets/mixins/__init__.pyi index cd4839b..13f2ac1 100644 --- a/src/brackets/mixins/__init__.pyi +++ b/src/brackets/mixins/__init__.pyi @@ -1,73 +1,71 @@ -from __future__ import annotations -from collections.abc import Callable -from typing import Any, Protocol, TypeVar +from typing import Any, ClassVar, Protocol from django.db.models import Model, QuerySet from django.http import HttpRequest, HttpResponse +from django.views.generic.base import ContextMixin from .access import * from .form_views import * from .forms import * from .http import * -from .misc import * from .queries import * from .redirects import * from .rest_framework import * -A = tuple[Any] # args -K = dict[Any, Any] # **kwargs - -Menu = dict[str, list[Any]] - -# Menu = dict[str, list[Any]] -StringOrMenu = TypeVar("StringOrMenu", bound=str | Menu) # String or Dict o' lists -RaiseOrCall = TypeVar( - "RaiseOrCall", bound=bool | Exception | Callable[[], Callable[[], bool]] -) - -class CanQuery(Protocol): - """The concept of a view that can query.""" - +class CanQuery(Protocol): # The concept of a view that can query. queryset: QuerySet[Model] def get_queryset(self: CanQuery) -> QuerySet[Model]: ... -class CanDispatch(Protocol): - """The concept of a view that can dispatch requests.""" +class CanDispatch(Protocol): # The concept of a view that can dispatch requests. + def dispatch( + self, request: HttpRequest, *args: tuple[Any, ...], **kwargs: dict[str, Any] + ) -> HttpResponse: ... - def dispatch(self, request: HttpRequest, *args: A, **kwargs: K) -> HttpResponse: ... +class _ContextProtocol(Protocol): + context: ClassVar[dict[str, Any]] -class HasContext(Protocol): - """The concept of `context`.""" + def get_context_data(self, **kwargs: dict[Any, Any]) -> dict[str, Any]: ... - context: K +class HasContext(_ContextProtocol, ContextMixin): # The concept of `context`. + context: ClassVar[dict[str, Any]] - def get_context_data(self) -> K: ... - -class HasRequest(Protocol): - """The existence of `self.request`.""" + def get_context_data(self, **kwargs: dict[Any, Any]) -> dict[str, Any]: ... +class HasRequest(Protocol): # The existence of `self.request`. request: HttpRequest -class HasContent(Protocol): - """The concept of `content_type`.""" - +class HasContent(Protocol): # The concept of `content_type`. content_type: str def get_content_type(self) -> str: ... -class HasHttpMethods(Protocol): - def delete(self, request: HttpRequest, *args: A, **kwargs: K) -> HttpResponse: ... - def get(self, request: HttpRequest, *args: A, **kwargs: K) -> HttpResponse: ... - def head(self, request: HttpRequest, *args: A, **kwargs: K) -> HttpResponse: ... - def options(self, request: HttpRequest, *args: A, **kwargs: K) -> HttpResponse: ... - def patch(self, request: HttpRequest, *args: A, **kwargs: K) -> HttpResponse: ... - def post(self, request: HttpRequest, *args: A, **kwargs: K) -> HttpResponse: ... - def put(self, request: HttpRequest, *args: A, **kwargs: K) -> HttpResponse: ... - def trace(self, request: HttpRequest, *args: A, **kwargs: K) -> HttpResponse: ... - -class _BaseView( - CanDispatch, HasContext, HasRequest, HasContent, HasHttpMethods, Protocol -): - """The concept of a view.""" - - def __init__(self, *args: A, **kwargs: K) -> None: ... +class HasHttpMethods(Protocol): # The concept of handling HTTP verbs. + def delete( + self, request: HttpRequest, *args: tuple[Any, ...], **kwargs: dict[str, Any] + ) -> HttpResponse: ... + def get( + self, request: HttpRequest, *args: tuple[Any, ...], **kwargs: dict[str, Any] + ) -> HttpResponse: ... + def head( + self, request: HttpRequest, *args: tuple[Any, ...], **kwargs: dict[str, Any] + ) -> HttpResponse: ... + def options( + self, request: HttpRequest, *args: tuple[Any, ...], **kwargs: dict[str, Any] + ) -> HttpResponse: ... + def patch( + self, request: HttpRequest, *args: tuple[Any, ...], **kwargs: dict[str, Any] + ) -> HttpResponse: ... + def post( + self, request: HttpRequest, *args: tuple[Any, ...], **kwargs: dict[str, Any] + ) -> HttpResponse: ... + def put( + self, request: HttpRequest, *args: tuple[Any, ...], **kwargs: dict[str, Any] + ) -> HttpResponse: ... + def trace( + self, request: HttpRequest, *args: tuple[Any, ...], **kwargs: dict[str, Any] + ) -> HttpResponse: ... + +class BaseView( + CanDispatch, _ContextProtocol, HasRequest, HasContent, HasHttpMethods, Protocol +): # The concept of a Django view. + def __init__(self, *args: tuple[Any, ...], **kwargs: dict[str, Any]) -> None: ... diff --git a/src/brackets/mixins/access.py b/src/brackets/mixins/access.py index f125815..7a67c19 100644 --- a/src/brackets/mixins/access.py +++ b/src/brackets/mixins/access.py @@ -9,17 +9,17 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.views import logout_then_login -from django.core.exceptions import BadRequest, ImproperlyConfigured from django.utils.timezone import now +from brackets.exceptions import BracketsConfigurationError + from .redirects import RedirectMixin if TYPE_CHECKING: # pragma: no cover from collections.abc import Callable - from typing import Any, Optional, TypeAlias + from typing import Any - A: TypeAlias = tuple[Any, ...] - K: TypeAlias = dict[Any, Any] + from django.db.models.base import ModelBase __all__: list[str] = [ "PassesTestMixin", @@ -34,7 +34,7 @@ "SSLRequiredMixin", ] -USER_MODEL = get_user_model() +USER_MODEL: ModelBase = get_user_model() class PassesTestMixin: @@ -44,47 +44,47 @@ class PassesTestMixin: another method is called to handle whatever comes next. """ - dispatch_test: Optional[str] = None + dispatch_test: str = "" def dispatch( - self, request: http.HttpRequest, *args: A, **kwargs: K + self, request: http.HttpRequest, *args: tuple[Any], **kwargs: dict[str, Any] ) -> http.HttpResponse: """Run the test method and dispatch the view if it passes.""" - test_method: Callable[[], bool] = self.get_test_method() + test_method: Callable[..., bool] = self.get_test_method() if not test_method(): - return self.handle_test_failure() + return self.handle_dispatch_test_failure() return super().dispatch(request, *args, **kwargs) - def get_test_method(self) -> Callable[[], bool]: + def get_test_method(self) -> Callable[..., bool]: """Find the method to test the request with. Provide a callable object or a string that can be used to look up a callable """ _class: str = self.__class__.__name__ - _test: str = self.dispatch_test # type: ignore + _test: str = self.dispatch_test _missing_error_message: str = ( f"{_class} is missing the `{_test}` method. " f"Define `{_class}.{_test}` or override " f"`{_class}.get_dispatch_test." ) _callable_error_message: str = f"{_class}.{_test} must be a callable." - if self.dispatch_test is None: - raise ImproperlyConfigured(_missing_error_message) + if not self.dispatch_test: + raise BracketsConfigurationError(_missing_error_message) try: - method: Callable = getattr(self, self.dispatch_test) + method: Callable[..., bool] = getattr(self, self.dispatch_test) except AttributeError as exc: - raise ImproperlyConfigured(_missing_error_message) from exc + raise BracketsConfigurationError(_missing_error_message) from exc - if not callable(method) or not method: - raise ImproperlyConfigured(_callable_error_message) + if not callable(method): + raise BracketsConfigurationError(_callable_error_message) return method - def handle_test_failure(self) -> http.HttpResponse: + def handle_dispatch_test_failure(self) -> http.HttpResponse: """Test failed, raise an exception or redirect.""" return http.HttpResponseBadRequest() @@ -95,7 +95,7 @@ class PassOrRedirectMixin(PassesTestMixin, RedirectMixin): redirect_url: str = "/" redirect_unauthenticated_users: bool = True - def handle_test_failure(self) -> http.HttpResponse: + def handle_dispatch_test_failure(self) -> http.HttpResponse: """Redirect a failed test.""" # redirect unauthenticated users to login if ( @@ -103,7 +103,7 @@ def handle_test_failure(self) -> http.HttpResponse: ) and self.redirect_unauthenticated_users: return self.redirect() - return super().handle_test_failure() + return super().handle_dispatch_test_failure() class SuperuserRequiredMixin(PassesTestMixin): @@ -145,18 +145,18 @@ def get_group_required(self) -> list[str]: f"attribute. Define `{_class}.group_required` or" f"override `{_class}.get_group_required()." ) - raise ImproperlyConfigured(_err_msg) + raise BracketsConfigurationError(_err_msg) if isinstance(self.group_required, str): return [self.group_required] return self.group_required def check_membership(self) -> bool: """Check the user's membership in the required groups.""" - return bool( - set(self.get_group_required()).intersection( - [group.name for group in self.request.user.groups.all()], # type: ignore - ), + groups_required: set[str] = set(self.get_group_required()) + user_groups: set[str] = set( + self.request.user.groups.values_list("name", flat=True) ) + return bool(groups_required.intersection(user_groups)) def check_groups(self) -> bool: """Check that the user is authenticated and a group member.""" @@ -207,18 +207,18 @@ def check_recent_login(self) -> bool: ) return False - def handle_test_failure(self) -> http.HttpResponseForbidden: - """Response is forbidden due to an expired login.""" - return http.HttpResponseForbidden() + def handle_dispatch_test_failure(self) -> http.HttpResponseRedirect: + """Logout the user and redirect to login.""" + return logout_then_login(self.request) class PermissionRequiredMixin(PassesTestMixin): """Require a user to have specific permission(s).""" - permission_required: str | dict[str, list] = "" + permission_required: str | dict[str, list[str]] = "" dispatch_test: str = "check_permissions" - def get_permission_required(self) -> dict[str, list]: + def get_permission_required(self) -> dict[str, list[str]]: """Return a dict of required and optional permissions.""" if not self.permission_required: _class: str = self.__class__.__name__ @@ -227,21 +227,21 @@ def get_permission_required(self) -> dict[str, list]: f"Define `{_class}.permission_required` or " f"override `{_class}.get_permission_required()`." ) - raise ImproperlyConfigured(_err_msg) + raise BracketsConfigurationError(_err_msg) if isinstance(self.permission_required, str): return {"all": [self.permission_required]} return self.permission_required def check_permissions(self) -> bool: """Check user for appropriate permissions.""" - permissions: dict[str, list] = self.get_permission_required() + permissions: dict[str, list[str]] = self.get_permission_required() _all: list[str] = permissions.get("all", []) _any: list[str] = permissions.get("any", []) if not getattr(self.request, "user", None): # type: ignore return False - perms_all = self.request.user.has_perms(_all) or [] # type: ignore - perms_any = [self.request.user.has_perm(perm) for perm in _any] # type: ignore + perms_all: list[bool] = self.request.user.has_perms(_all) or [] + perms_any: list[bool] = [self.request.user.has_perm(perm) for perm in _any] return any((perms_all, any(perms_any))) @@ -260,10 +260,12 @@ def test_ssl(self) -> bool: return self.request.is_secure() # type: ignore - def handle_test_failure(self) -> http.HttpResponse | BadRequest: + def handle_dispatch_test_failure( + self, + ) -> http.HttpResponse | http.HttpResponseBadRequest: """Redirect to the SSL version of the request's URL.""" if self.redirect_to_ssl: - current = self.request.build_absolute_uri(self.request.get_full_path()) # type: ignore - secure = current.replace("http://", "https://") + current: str = self.request.build_absolute_uri(self.request.get_full_path()) + secure: str = current.replace("http://", "https://") return http.HttpResponsePermanentRedirect(secure) - return super().handle_test_failure() + return super().handle_dispatch_test_failure() diff --git a/src/brackets/mixins/access.pyi b/src/brackets/mixins/access.pyi index a9c1526..33d6995 100644 --- a/src/brackets/mixins/access.pyi +++ b/src/brackets/mixins/access.pyi @@ -1,5 +1,5 @@ from collections.abc import Callable -from typing import Any, Optional +from typing import Any from django.http import ( HttpRequest, @@ -10,13 +10,14 @@ from django.http import ( ) from django.views.generic.base import View -from . import A, HasRequest, K, Menu from .redirects import RedirectMixin class PassesTestMixin(View): dispatch_test: str request: HttpRequest - def dispatch(self, request: HttpRequest, *args: A, **kwargs: K) -> HttpResponse: ... + def dispatch( + self, request: HttpRequest, *args: tuple[Any, ...], **kwargs: dict[Any, Any] + ) -> HttpResponse: ... def get_test_method(self) -> Callable[[Any], bool]: ... def handle_test_failure(self) -> HttpResponse: ... @@ -62,9 +63,9 @@ class RecentLoginRequiredMixin(PassesTestMixin): def handle_test_failure(self) -> HttpResponseRedirect: ... class PermissionRequiredMixin(PassesTestMixin): - permission_required: Optional[Menu] + permission_required: str | dict[str, list[str]] dispatch_test: str - def get_permission_required(self) -> Menu: ... + def get_permission_required(self) -> dict[str, list[str]]: ... def check_permissions(self) -> bool: ... class SSLRequiredMixin(PassesTestMixin): diff --git a/src/brackets/mixins/form_views.py b/src/brackets/mixins/form_views.py index 992b3f7..fe887d4 100644 --- a/src/brackets/mixins/form_views.py +++ b/src/brackets/mixins/form_views.py @@ -2,17 +2,20 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Optional from django import forms -from django.core.exceptions import ImproperlyConfigured +from django.forms.forms import BaseForm from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt +from django.views.generic.base import ContextMixin +from django.views.generic.edit import FormMixin +from brackets.exceptions import BracketsConfigurationError from brackets.mixins.forms import UserFormMixin if TYPE_CHECKING: - from typing import Any, Optional + from typing import Mapping, Sequence from django.db import models from django.http import HttpRequest, HttpResponse @@ -24,18 +27,18 @@ ] -class FormWithUserMixin: +class FormWithUserMixin(FormMixin): """Automatically provide request.user to the form's kwargs.""" - def get_form_kwargs(self) -> dict: + def get_form_kwargs(self) -> dict[str, Any]: """Inject the request.user into the form's kwargs.""" - kwargs: dict[Any, Any] = super().get_form_kwargs() + kwargs: dict[str, Any] = super().get_form_kwargs() kwargs.update({"user": self.request.user}) return kwargs - def get_form_class(self) -> type[forms.Form]: + def get_form_class(self) -> type[UserFormMixin]: """Get the form class or wrap it with UserFormMixin.""" - form_class: type[forms.Form] = super().get_form_class() + form_class: type["FormWithUserMixin"] = super().get_form_class() if issubclass(form_class, UserFormMixin): return form_class @@ -49,7 +52,9 @@ class CSRFExemptMixin: """Exempts the view from CSRF requirements.""" @method_decorator(csrf_exempt) - def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + def dispatch( + self, request: HttpRequest, *args: Sequence[Any], **kwargs: Mapping[str, Any] + ) -> HttpResponse: """Dispatch the exempted request.""" return super().dispatch(request, *args, **kwargs) @@ -57,96 +62,92 @@ def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: CsrfExemptMixin = CSRFExemptMixin -class MultipleFormsMixin: +class MultipleFormsMixin(FormMixin): """Provides a view with the ability to handle multiple Forms.""" - form_classes: dict[str, forms.Form] = None - form_initial_values: dict[str, dict] = {} - form_instances: dict[str, models.Model] = None + form_classes: Optional[Mapping[str, type[forms.BaseForm]]] = None + form_initial_values: Optional[Mapping[str, Mapping[str, Any]]] = None + form_instances: Optional[Mapping[str, models.Model]] = None - def __init__(self, *args, **kwargs) -> None: - """Alias get_forms to get_form for backwards compatibility.""" - super().__init__(*args, **kwargs) - self.get_form = self.get_forms - - def get_context_data(self, **kwargs) -> dict: + def get_context_data(self, **kwargs: dict[str, Any]) -> dict[str, Any]: """Add the forms to the view context.""" - context = super().get_context_data(**kwargs) - context["forms"] = self.get_forms() - return context + kwargs.setdefault("view", self) + if self.extra_context is not None: + kwargs.update(self.extra_context) + kwargs["forms"] = self.get_forms() + return kwargs - def get_form_classes(self) -> list: + def get_form_classes(self) -> Mapping[str, type[forms.BaseForm]]: """Get the form classes to use in this view.""" - _class = self.__class__.__name__ - if self.form_classes is None: + _class: str = self.__class__.__name__ + if not self.form_classes: _err_msg = ( f"{_class} is missing a form_classes attribute. " f"Define `{_class}.form_classes`, or override " f"`{_class}.get_form_classes()`." ) - raise ImproperlyConfigured(_err_msg) + raise BracketsConfigurationError(_err_msg) if not isinstance(self.form_classes, dict): _err_msg = f"`{_class}.form_classes` must be a dict." - raise ImproperlyConfigured(_err_msg) + raise BracketsConfigurationError(_err_msg) return self.form_classes - def get_forms(self) -> dict[str, forms.Form]: + def get_forms(self) -> dict[str, forms.BaseForm]: """Instantiate the forms with their kwargs.""" - _forms = {} + forms: dict[str, forms.BaseForm] = {} for name, form_class in self.get_form_classes().items(): - _forms[name] = form_class(**self.get_form_kwargs(name)) - return _forms + forms[name] = form_class(**self.get_form_kwargs(name)) + return forms - def get_instance(self, name: str) -> Optional[models.Model]: + def get_instance(self, name: str) -> models.Model: """Connect instances to forms.""" _class = self.__class__.__name__ - if self.form_instances is None: + if not self.form_instances: _err_msg = ( f"{_class} is missing a `form_instances` attribute." f"Define `{_class}.form_instances`, or override " f"`{_class}.get_instances`." ) - raise ImproperlyConfigured(_err_msg) + raise BracketsConfigurationError(_err_msg) if not isinstance(self.form_instances, dict): _err_msg = f"`{_class}.form_instances` must be a dictionary." - raise ImproperlyConfigured(_err_msg) + raise BracketsConfigurationError(_err_msg) try: instance = self.form_instances[name] except (KeyError, ValueError) as exc: _err_msg = f"`{name}` is not an available instance." - raise ImproperlyConfigured(_err_msg) from exc + raise BracketsConfigurationError(_err_msg) from exc else: return instance - def get_initial(self, name: str) -> Optional[dict[str, Any]]: + def get_initial(self, name: str) -> dict[str, Any]: # type: ignore """Connect instances to forms.""" - _class = self.__class__.__name__ if self.form_initial_values is None: + return {} + + _class = self.__class__.__name__ + if not self.form_initial_values or isinstance(self.form_initial_values, str): _err_msg = ( f"{_class} is missing a `form_initial_values` attribute." f"Define `{_class}.form_initial_values`, or override " f"`{_class}.get_initial`." ) - raise ImproperlyConfigured(_err_msg) - - if not isinstance(self.form_initial_values, dict): - _err_msg = f"`{_class}.form_initial_values` must be a dictionary." - raise ImproperlyConfigured(_err_msg) + raise BracketsConfigurationError(_err_msg) try: - initial = self.form_initial_values[name] - except KeyError: + initial: Any = self.form_initial_values[name] + except (TypeError, KeyError): return {} else: return initial - def get_form_kwargs(self, name: str) -> dict[str, Any]: + def get_form_kwargs(self, name: str) -> dict[str, Any]: # type: ignore """Add common kwargs to the form.""" - kwargs = { + kwargs: dict[str, Any] = { "prefix": name, # all forms get a prefix } @@ -176,16 +177,22 @@ def forms_invalid(self) -> HttpResponse: """Handle any form being invalid.""" raise NotImplementedError - def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + def post( + self, request: HttpRequest, *args: Sequence[Any], **kwargs: Mapping[str, Any] + ) -> HttpResponse: """Process POST requests: validate and run appropriate handler.""" if self.validate_forms(): return self.forms_valid() return self.forms_invalid() - def put(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + def put( + self, request: HttpRequest, *args: Sequence[Any], **kwargs: Mapping[str, Any] + ) -> HttpResponse: """Process PUT requests.""" raise NotImplementedError - def patch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + def patch( + self, request: HttpRequest, *args: Sequence[Any], **kwargs: Mapping[str, Any] + ) -> HttpResponse: """Process PATCH requests.""" raise NotImplementedError diff --git a/src/brackets/mixins/form_views.pyi b/src/brackets/mixins/form_views.pyi index 0862f87..d62be91 100644 --- a/src/brackets/mixins/form_views.pyi +++ b/src/brackets/mixins/form_views.pyi @@ -1,45 +1,44 @@ -from typing import Any, Protocol, Type +from typing import Any, Optional, Protocol, Type from django import forms from django.db import models from django.http import HttpRequest, HttpResponse -from . import A, CanDispatch, HasContext, HasHttpMethods, HasRequest, K +from . import CanDispatch, HasContext, HasHttpMethods, HasRequest class HasForm(Protocol): form_class: type[forms.Form] - form_kwargs: K + form_kwargs: dict[str, Any] def get_form_class(self) -> type[forms.Form]: ... - def get_form_kwargs(self) -> dict[Any, Any]: ... + def get_form_kwargs(self) -> dict[str, Any]: ... class FormWithUserMixin(HasRequest, HasForm, CanDispatch): form_class: Type[forms.Form] - form_kwargs: K + form_kwargs: dict[str, Any] request: HttpRequest - def get_form_kwargs(self) -> dict[Any, Any]: ... + def get_form_kwargs(self) -> dict[str, Any]: ... def get_form_class(self) -> Type[forms.Form]: ... class CSRFExemptMixin: def dispatch( self, request: HttpRequest, - *args: A, - **kwargs: K, + args: tuple[Any, ...], + kwargs: dict[str, Any], ) -> HttpResponse: ... CsrfExemptMixin = CSRFExemptMixin class MultipleFormsMixin(HasContext, HasHttpMethods): - context: K + extra_context: Optional[dict[str, Any]] = None form_classes: dict[str, forms.Form] - form_initial_values: dict[str, dict[Any, Any]] + form_initial_values: dict[str, dict[str, Any]] form_instances: dict[str, models.Model] get_form: type[forms.Form] - def __init__(self, *args: A, **kwargs: K) -> None: ... def forms_valid(self) -> HttpResponse: ... def forms_invalid(self) -> HttpResponse: ... def get_form_classes(self) -> list[forms.Form]: ... def get_forms(self) -> dict[str, forms.Form]: ... - def get_form_kwargs(self, name: str) -> K: ... + def get_form_kwargs(self, name: str) -> dict[str, Any]: ... def get_instances(self) -> dict[str, models.Model]: ... def validate_forms(self) -> bool: ... diff --git a/src/brackets/mixins/forms.py b/src/brackets/mixins/forms.py index 96f5421..f75a7c4 100644 --- a/src/brackets/mixins/forms.py +++ b/src/brackets/mixins/forms.py @@ -2,8 +2,15 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from django import forms +if TYPE_CHECKING: + from typing import Any + + from django.db.models import Model + __all__ = [ "UserFormMixin", ] @@ -12,12 +19,12 @@ class UserFormMixin: """Automatically pop request.user from the form's kwargs.""" - def __init__(self, *args, **kwargs) -> None: + def __init__(self, *args: tuple[Any], **kwargs: dict[str, Any]) -> None: """Add the user to the form's kwargs.""" if not issubclass(self.__class__, forms.Form): _err_msg = "`UserFormMixin` can only be used with forms or modelforms." raise TypeError(_err_msg) if "user" in kwargs: - self.user = kwargs.pop("user") + self.user: Model = kwargs.pop("user") super().__init__(*args, **kwargs) diff --git a/src/brackets/mixins/forms.pyi b/src/brackets/mixins/forms.pyi index 163a83e..73870e1 100644 --- a/src/brackets/mixins/forms.pyi +++ b/src/brackets/mixins/forms.pyi @@ -1,9 +1,7 @@ -from typing import Type +from typing import Any, Type from django.db import models -from . import A, K - class UserFormMixin: user: Type[models.Model] - def __init__(self, *args: A, **kwargs: K) -> None: ... + def __init__(self, *args: tuple[Any, ...], **kwargs: dict[str, Any]) -> None: ... diff --git a/src/brackets/mixins/http.py b/src/brackets/mixins/http.py index e173b8d..8b447d7 100644 --- a/src/brackets/mixins/http.py +++ b/src/brackets/mixins/http.py @@ -2,37 +2,40 @@ from __future__ import annotations -import typing +from typing import TYPE_CHECKING, Any, Optional, TypeAlias -from django.core.exceptions import ImproperlyConfigured +from django.http import HttpRequest, HttpResponse +from django.views.generic import View from django.views.decorators.cache import cache_control, never_cache -if typing.TYPE_CHECKING: # pragma: no cover +from brackets.exceptions import BracketsConfigurationError + +if TYPE_CHECKING: # pragma: no cover from collections.abc import Callable - from django.http import HttpRequest, HttpResponse __all__ = ["AllVerbsMixin", "HeaderMixin", "CacheControlMixin", "NeverCacheMixin"] +A: TypeAlias = tuple[Any] +K: TypeAlias = dict[str, Any] class AllVerbsMixin: """Handle all HTTP verbs with a single method.""" all_verb_handler: str = "all" - def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + def dispatch(self, request: HttpRequest, *args: A, **kwargs: K) -> HttpResponse: """Run all requests through the all_verb_handler method.""" if not self.all_verb_handler: - err = ( - f"{self.__class__.__name__} requires the all_verb_handler " - "attribute to be set." + raise BracketsConfigurationError( + "%s requires the all_verb_handler attribute to be set." + % self.__class__.__name__ ) - raise ImproperlyConfigured(err) handler = getattr(self, self.all_verb_handler, self.http_method_not_allowed) return handler(request, *args, **kwargs) - def all(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + def all(self, request: HttpRequest, *args: A, **kwargs: K) -> HttpResponse: """Handle all requests.""" raise NotImplementedError @@ -40,15 +43,21 @@ def all(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: class HeaderMixin: """Mixin for easily adding headers to a response.""" - headers: dict = None + headers: Optional[dict[str, Any]] = None - def get_headers(self) -> dict: + def get_headers(self) -> dict[str, Any]: """Return a dictionary of headers to add to the response.""" if self.headers is None: - self.headers = {} + return {} + + if not self.headers: + raise BracketsConfigurationError( + "%s requires the `headers` attribute to be set." + % self.__class__.__name__ + ) return self.headers - def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + def dispatch(self, request: HttpRequest, *args: A, **kwargs: K) -> HttpResponse: """Add headers to the response.""" response = super().dispatch(request, *args, **kwargs) for key, value in self.get_headers().items(): @@ -59,29 +68,49 @@ def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: class CacheControlMixin: """Provides a view with cache control options.""" - cache_control_public: bool = None - cache_control_private: bool = None - cache_control_no_cache: bool = None - cache_control_no_store: bool = None - cache_control_no_transform: bool = None - cache_control_must_revalidate: bool = None - cache_control_proxy_revalidate: bool = None - cache_control_max_age: int = None - cache_control_s_maxage: int = None + cache_control_public: Optional[bool] = None + cache_control_private: Optional[bool] = None + cache_control_no_cache: Optional[bool] = None + cache_control_no_store: Optional[bool] = None + cache_control_no_transform: Optional[bool] = None + cache_control_must_revalidate: Optional[bool] = None + cache_control_proxy_revalidate: Optional[bool] = None + cache_control_max_age: Optional[int] = None + cache_control_s_maxage: Optional[int] = None + + def __init__(self, **kwargs: dict[str, None | bool | int]) -> None: + """Set up Cache Control.""" + self.cache_control_public = kwargs.pop("cache_control_public", None) + self.cache_control_private = kwargs.pop("cache_control_private", None) + self.cache_control_no_cache = kwargs.pop("cache_control_no_cache", None) + self.cache_control_no_store = kwargs.pop("cache_control_no_store", None) + self.cache_control_no_transform = kwargs.pop("cache_control_no_transform", None) + self.cache_control_must_revalidate = kwargs.pop( + "cache_control_must_revalidate", None + ) + self.cache_control_proxy_revalidate = kwargs.pop( + "cache_control_proxy_revalidate", None + ) + self.cache_control_max_age = kwargs.pop("cache_control_max_age", None) + self.cache_control_s_maxage = kwargs.pop("cache_control_s_maxage", None) + + super().__init__(**kwargs) @classmethod - def get_cache_control_options(cls) -> dict: + def get_cache_control_options( + cls: type["CacheControlMixin"], + ) -> dict[str, bool | int]: """Get the view's cache-control options.""" - options = {} + options: dict[str, bool | int] = {} for key, value in cls.__dict__.items(): if key.startswith("cache_control_") and value is not None: options[key.replace("cache_control_", "")] = value return options @classmethod - def as_view(cls, **initkwargs: dict) -> Callable: + def as_view(cls: type["CacheControlMixin"], **initkwargs: dict[str, Any]) -> View: """Add cache control to the view.""" - view = super().as_view(**initkwargs) + view: Callable[[View], HttpResponse] = super().as_view(**initkwargs) return cache_control(**cls.get_cache_control_options())(view) @@ -89,7 +118,7 @@ class NeverCacheMixin: """Prevents a view from being cached.""" @classmethod - def as_view(cls, **initkwargs: dict) -> Callable: + def as_view(cls: type["NeverCacheMixin"], **initkwargs: dict[str, Any]) -> View: """Wrap the view with never_cache.""" - view = super().as_view(**initkwargs) + view: Callable[[View], HttpResponse] = super().as_view(**initkwargs) return never_cache(view) diff --git a/src/brackets/mixins/http.pyi b/src/brackets/mixins/http.pyi index b51366e..e4f3431 100644 --- a/src/brackets/mixins/http.pyi +++ b/src/brackets/mixins/http.pyi @@ -1,8 +1,11 @@ -from typing import Any +from typing import Any, Callable, ClassVar, Optional, TypeAlias from django.http import HttpRequest, HttpResponse +from django.http.response import HttpResponseBase +from django.views.generic.base import View -from . import A, K +A: TypeAlias = tuple[Any, ...] +K: TypeAlias = dict[str, Any] class AllVerbsMixin: all_verb_handler: str @@ -10,25 +13,30 @@ class AllVerbsMixin: def all(self, request: HttpRequest, *args: A, **kwargs: K) -> HttpResponse: ... class HeaderMixin: - headers: dict[str, Any] + headers: ClassVar[dict[str, Any]] def get_headers(self) -> dict[str, Any]: ... def dispatch(self, request: HttpRequest, *args: A, **kwargs: K) -> HttpResponse: ... -class CacheControlMixin: - cache_control_public: bool - cache_control_private: bool - cache_control_no_cache: bool - cache_control_no_store: bool - cache_control_no_transform: bool - cache_control_must_revalidate: bool - cache_control_proxy_revalidate: bool - cache_control_max_age: int - cache_control_s_maxage: int +class CacheControlMixin(View): + cache_control_public: Optional[bool] + cache_control_private: Optional[bool] + cache_control_no_cache: Optional[bool] + cache_control_no_store: Optional[bool] + cache_control_no_transform: Optional[bool] + cache_control_must_revalidate: Optional[bool] + cache_control_proxy_revalidate: Optional[bool] + cache_control_max_age: Optional[int] + cache_control_s_maxage: Optional[int] @classmethod - def get_cache_control_options(cls) -> dict[Any, Any]: ... + def get_cache_control_options( + cls: type[CacheControlMixin], + ) -> dict[str, bool | int]: ... @classmethod - def as_view(cls, **initkwargs: K) -> HttpResponse: ... + def as_view( + cls: type[CacheControlMixin], + **initkwargs: K, + ) -> Callable[..., HttpResponseBase]: ... class NeverCacheMixin: @classmethod - def as_view(cls, **initkwargs: K) -> HttpResponse: ... + def as_view(cls: type[NeverCacheMixin], **initkwargs: K) -> HttpResponse: ... diff --git a/src/brackets/mixins/misc.py b/src/brackets/mixins/misc.py deleted file mode 100644 index 9fbb1ab..0000000 --- a/src/brackets/mixins/misc.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Mixins that don't have a better home.""" -from __future__ import annotations - -from typing import Any - -from django.core.exceptions import ImproperlyConfigured - -__all__ = ["StaticContextMixin"] - - -class StaticContextMixin: - """A mixin for adding static items to the context.""" - - static_context: dict = None - - def get_static_context(self) -> dict: - """Get the static context to add to the view's context.""" - _class = self.__class__.__name__ - if self.static_context is None: - _err_msg = ( - f"{_class} is missing the static_context attribute. " - f"Define `{_class}.static_context`, or override " - f"`{_class}.get_static_context()`." - ) - raise ImproperlyConfigured(_err_msg) - - if not isinstance(self.static_context, dict): - _err_msg = f"{_class}.static_context must be a dictionary." - raise ImproperlyConfigured(_err_msg) - return self.static_context - - def get_context_data(self) -> dict[str, Any]: - """Add the static context to the view's context.""" - context = super().get_context_data() - context.update(self.get_static_context()) - return context diff --git a/src/brackets/mixins/misc.pyi b/src/brackets/mixins/misc.pyi deleted file mode 100644 index 740d300..0000000 --- a/src/brackets/mixins/misc.pyi +++ /dev/null @@ -1,7 +0,0 @@ -from . import HasContext, K - -class StaticContextMixin(HasContext): - context: K - static_context: K - def get_static_context(self) -> K: ... - def get_context_data(self) -> K: ... diff --git a/src/brackets/mixins/queries.py b/src/brackets/mixins/queries.py index ae04bf8..82f7c21 100644 --- a/src/brackets/mixins/queries.py +++ b/src/brackets/mixins/queries.py @@ -1,13 +1,13 @@ -"r" "Mixins related to Django ORM queries." "" +"""Mixins related to Django ORM queries.""" from __future__ import annotations from typing import TYPE_CHECKING -from django.core.exceptions import ImproperlyConfigured +from brackets.exceptions import BracketsConfigurationError if TYPE_CHECKING: # pragma: no cover - from typing import Iterable + from typing import Sequence from django.db.models import Model, QuerySet @@ -17,21 +17,21 @@ class SelectRelatedMixin: """A mixin for adding select_related to the queryset.""" - select_related = None + select_related: str | Sequence[str] = "" - def get_select_related(self) -> list[str]: + def get_select_related(self) -> Sequence[str]: """Get the fields to be select_related.""" _class = self.__class__.__name__ - if not getattr(self, "select_related", None) or not self.select_related: + if not self.select_related: _err_msg = ( f"{_class} is missing the select_related attribute. " f"Define `{_class}.select_related`, or override " f"`{_class}.get_select_related()`." ) - raise ImproperlyConfigured(_err_msg) + raise BracketsConfigurationError(_err_msg) - if not isinstance(self.select_related, (tuple, list)): + if isinstance(self.select_related, str): self.select_related = [self.select_related] return self.select_related @@ -46,25 +46,25 @@ def get_queryset(self) -> QuerySet[Model]: class PrefetchRelatedMixin: """A mixin for adding prefetch_related to the queryset.""" - prefetch_related = None + prefetch_related: str | Sequence[str] = "" - def get_prefetch_related(self) -> list[str]: + def get_prefetch_related(self) -> Sequence[str]: """Get the fields to be prefetch_related.""" _class = self.__class__.__name__ - if not getattr(self, "prefetch_related", None) or not self.prefetch_related: + if not self.prefetch_related: _err_msg = ( f"{_class} is missing the prefetch_related attribute. " f"Define `{_class}.prefetch_related`, or override " f"`{_class}.get_prefetch_related()`." ) - raise ImproperlyConfigured(_err_msg) + raise BracketsConfigurationError(_err_msg) - if not isinstance(self.prefetch_related, (tuple, list)): + if isinstance(self.prefetch_related, str): self.prefetch_related = [self.prefetch_related] return self.prefetch_related - def get_queryset(self) -> QuerySet: + def get_queryset(self) -> QuerySet[Model]: """Add prefetch_related to the queryset.""" queryset: QuerySet[Model] = super().get_queryset() prefetch_related = self.get_prefetch_related() @@ -74,11 +74,11 @@ def get_queryset(self) -> QuerySet: class OrderableListMixin: """A mixin for adding query-string based ordering to the queryset.""" - orderable_fields = None - orderable_field_default = None - orderable_direction_default = "asc" + orderable_fields: str | Sequence[str] = "" + orderable_field_default = "" + orderable_direction_default: str = "asc" # "asc" or "desc" - def get_orderable_fields(self) -> list[str]: + def get_orderable_fields(self) -> Sequence[str]: """Get fields to use for ordering.""" if not self.orderable_fields: _class = self.__class__.__name__ @@ -87,7 +87,9 @@ def get_orderable_fields(self) -> list[str]: f"Define `{_class}.orderable_fields`, or override " f"`{_class}.get_orderable_fields()`." ) - raise ImproperlyConfigured(_err_msg) + raise BracketsConfigurationError(_err_msg) + if isinstance(self.orderable_fields, str): + self.orderable_fields = [self.orderable_fields] return self.orderable_fields def get_orderable_field_default(self) -> str: @@ -99,7 +101,7 @@ def get_orderable_field_default(self) -> str: f"Define `{_class}.orderable_field_default`, or override " f"`{_class}.get_orderable_field_default()`." ) - raise ImproperlyConfigured(_err_msg) + raise BracketsConfigurationError(_err_msg) return self.orderable_field_default def get_orderable_direction_default(self) -> str: @@ -108,29 +110,28 @@ def get_orderable_direction_default(self) -> str: if not direction or direction not in ["asc", "desc"]: _class = self.__class__.__name__ _err_msg = f"{_class}.orderable_direction_default must be 'asc' or 'desc'." - raise ImproperlyConfigured(_err_msg) + raise BracketsConfigurationError(_err_msg) return direction - def get_order_from_request(self) -> Iterable[str]: + def get_order_from_request(self) -> Sequence[str]: """Use the query string to determine the ordering.""" - request_kwargs = self.request.GET.dict() - field = request_kwargs.get("order_by", "").lower() - direction = request_kwargs.get("order_dir", "").lower() + field: str = self.request.GET.get("order_by", "").lower() + direction: str = self.request.GET.get("order_dir", "").lower() if not field: - field = self.get_orderable_field_default() + field: str = self.get_orderable_field_default() if not direction: - direction = self.get_orderable_direction_default() + direction: str = self.get_orderable_direction_default() return field, direction - def get_queryset(self) -> QuerySet: + def get_queryset(self) -> QuerySet[Model]: """Order the queryset.""" queryset: QuerySet[Model] = super().get_queryset() field, direction = self.get_order_from_request() - allowed_fields = self.get_orderable_fields() + allowed_fields: Sequence[str] = self.get_orderable_fields() - direction = "-" if direction == "desc" else "" + direction: str = "-" if direction == "desc" else "" if field in allowed_fields: return queryset.order_by(f"{direction}{field}") diff --git a/src/brackets/mixins/queries.pyi b/src/brackets/mixins/queries.pyi index 35d8f21..b9ea773 100644 --- a/src/brackets/mixins/queries.pyi +++ b/src/brackets/mixins/queries.pyi @@ -1,27 +1,30 @@ -from typing import Iterable +from collections.abc import Iterable from django.db.models import Model, QuerySet from django.http import HttpRequest -from . import A, K +from . import A, CanQuery, HasRequest, K -class SelectRelatedMixin: - select_related: K +class SelectRelatedMixin(CanQuery): + queryset: QuerySet[Model] + select_related: str | list[str] | tuple[str] def get_select_related(self) -> list[str]: ... def get_queryset(self) -> QuerySet[Model]: ... -class PrefetchRelatedMixin: +class PrefetchRelatedMixin(CanQuery): prefetch_related: K + queryset: QuerySet[Model] def get_prefetch_related(self) -> list[str]: ... def get_queryset(self) -> QuerySet[Model]: ... -class OrderableListMixin: +class OrderableListMixin(CanQuery, HasRequest): request: HttpRequest orderable_fields: list[str] orderable_field_default: str orderable_direction_default: str + queryset: QuerySet[Model] def __init__(self, *args: A, **kwargs: K) -> None: ... - def get_orderable_fields(self) -> list[str]: ... + def get_orderable_fields(self) -> Iterable[str]: ... def get_orderable_field_default(self) -> str: ... def get_orderable_direction_default(self) -> str: ... def get_order_from_request(self) -> Iterable[str]: ... diff --git a/src/brackets/mixins/redirects.py b/src/brackets/mixins/redirects.py index a8dc90a..23e7526 100644 --- a/src/brackets/mixins/redirects.py +++ b/src/brackets/mixins/redirects.py @@ -2,15 +2,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from django import http from django.conf import settings from django.contrib.auth.views import redirect_to_login -from django.core.exceptions import ImproperlyConfigured -if TYPE_CHECKING: - from typing import Optional # pragma: no cover +from brackets.exceptions import BracketsConfigurationError __all__ = [ "RedirectMixin", @@ -19,7 +15,11 @@ class RedirectMixin: - """Mixin to simplify redirecting a request.""" + """Mixin to simplify redirecting a request. + + This mixin is largely for internal use. You are + probably looking for Django's `RedirectView`. + """ redirect_url: str = "" @@ -29,26 +29,26 @@ def redirect(self) -> http.HttpResponseRedirect: def get_redirect_url(self) -> str: """Get the URL to redirect to.""" - _url = getattr(self, "redirect_url", None) - if _url is None or not _url: + url = getattr(self, "redirect_url", None) + if not url: _class = self.__class__.__name__ _err_msg = ( f"{_class} is missing the `redirect_url` attribute. " f"Define `{_class}.redirect_url` or override " f"`{_class}.get_redirect_url`." ) - raise ImproperlyConfigured(_err_msg) + raise BracketsConfigurationError(_err_msg) return self.redirect_url -class RedirectToLoginMixin(RedirectMixin): +class RedirectToLoginMixin: """Redirect failed requests to `LOGIN_URL`.""" - login_url: Optional[str] = None + login_url: str = "" def get_login_url(self) -> str: """Return the URL for the login page.""" - if self.login_url is None: + if not self.login_url: try: self.login_url = settings.LOGIN_URL except AttributeError as exc: @@ -58,7 +58,7 @@ def get_login_url(self) -> str: f"Define `{_class}.login_url` or `settings.LOGIN_URL`." f"Alternatively, override `{_class}.get_login_url`." ) - raise ImproperlyConfigured(_err_msg) from exc + raise BracketsConfigurationError(_err_msg) from exc return self.login_url def redirect(self) -> http.HttpResponseRedirect: diff --git a/src/brackets/mixins/redirects.pyi b/src/brackets/mixins/redirects.pyi index b44c6a4..636573a 100644 --- a/src/brackets/mixins/redirects.pyi +++ b/src/brackets/mixins/redirects.pyi @@ -1,15 +1,13 @@ -from typing import Optional - from django import http class RedirectMixin: - redirect_url: Optional[str] + redirect_url: str request: http.HttpRequest def redirect(self) -> http.HttpResponseRedirect: ... def get_redirect_url(self) -> str: ... class RedirectToLoginMixin(RedirectMixin): - login_url: Optional[str] + login_url: str request: http.HttpRequest def get_login_url(self) -> str: ... def redirect(self) -> http.HttpResponseRedirect: ... diff --git a/src/brackets/mixins/rest_framework.py b/src/brackets/mixins/rest_framework.py index 5ae8468..3862b65 100644 --- a/src/brackets/mixins/rest_framework.py +++ b/src/brackets/mixins/rest_framework.py @@ -2,12 +2,12 @@ from __future__ import annotations -import typing +from typing import TYPE_CHECKING, Any -from django.core.exceptions import ImproperlyConfigured +from brackets.exceptions import BracketsConfigurationError -if typing.TYPE_CHECKING: # pragma: no cover - from typing import Dict, Type +if TYPE_CHECKING: # pragma: no cover + from typing import ClassVar, Optional from rest_framework.serializers import Serializer @@ -22,26 +22,20 @@ class MultipleSerializersMixin: different set of fields for a GET request than for a POST request. """ - serializer_classes: Dict[str, Type[Serializer]] = None + serializer_classes: ClassVar[Optional[dict[str, type[Serializer[Any]]]]] = None - def get_serializer_classes(self) -> dict[str, Type[Serializer]]: + def get_serializer_classes(self) -> dict[str, type[Serializer[Any]]]: """Get necessary serializer classes.""" - _class = self.__class__.__name__ - if self.serializer_classes is None: - _err_msg = ( - f"{_class} is missing the serializer_classes attribute. " - f"Define `{_class}.serializer_classes`, or override " - f"`{_class}.get_serializer_classes()`." + if not self.serializer_classes: + raise BracketsConfigurationError( + "'%s' should either include a `serializer_classes` attribute, " + "or override the `get_serializer_classes()` method." + % self.__class__.__name__ ) - raise ImproperlyConfigured(_err_msg) - - if not isinstance(self.serializer_classes, (dict, list, tuple)): - _err_msg = f"{_class}.serializer_classes must be a dictionary." - raise ImproperlyConfigured(_err_msg) return self.serializer_classes - def get_serializer_class(self) -> Type[Serializer]: + def get_serializer_class(self) -> type[Serializer[Any]]: """Get the serializer class to use for this request. Defaults to using `super().serializer_class`. @@ -52,4 +46,6 @@ def get_serializer_class(self) -> Type[Serializer]: (E.g. admins get full serialization, others get basic serialization) """ serializer_classes = self.get_serializer_classes() + if not serializer_classes: + return super().get_serializer_class() return serializer_classes[self.request.method.lower()] diff --git a/src/brackets/mixins/rest_framework.pyi b/src/brackets/mixins/rest_framework.pyi index 1866d52..84237e7 100644 --- a/src/brackets/mixins/rest_framework.pyi +++ b/src/brackets/mixins/rest_framework.pyi @@ -1,8 +1,10 @@ -from typing import Any +from typing import Any, Optional +from django.http import HttpRequest from rest_framework.serializers import Serializer class MultipleSerializersMixin: - serializer_classes: dict[str, Serializer[Any]] + request: HttpRequest + serializer_classes: Optional[dict[str, type[Serializer[Any]]]] def get_serializer_classes(self) -> dict[str, Serializer[Any]]: ... - def get_serializer_class(self) -> Serializer[Any]: ... + def get_serializer_class(self) -> type[Serializer[Any]]: ... diff --git a/src/brackets/py.typed b/src/brackets/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py index e2953be..1990e95 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,10 +2,10 @@ from __future__ import annotations from importlib import import_module -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar, Mapping, TypeVar import pytest -from django import forms +from django.forms import Form, ModelForm from django.http import HttpResponse from django.views.generic import View from django.views.generic.detail import SingleObjectMixin @@ -16,21 +16,21 @@ if TYPE_CHECKING: from collections.abc import Callable - from typing import Type + from typing import Any, TypeVar from django.db import models - K = dict + K = Mapping[str, Any] @pytest.mark.django_db() @pytest.fixture() -def user(django_user_model) -> Callable: +def user(django_user_model: type[models.Model]) -> Callable[[K], models.Model]: """Provide a generic user fixture for tests.""" - def _user(**kwargs: K) -> models.Model: + def _user(**kwargs: dict[str, Any]) -> models.Model: """Generate a customizable user.""" - defaults: K = {"username": "test", "password": "Test1234"} + defaults: dict[str, str] = {"username": "test", "password": "Test1234"} defaults.update(kwargs) user: models.Model = django_user_model.objects.create(**defaults) return user @@ -39,7 +39,7 @@ def _user(**kwargs: K) -> models.Model: @pytest.fixture(name="mixin_view") -def mixin_view_factory(request: pytest.FixtureRequest) -> Callable: +def mixin_view_factory(request: pytest.FixtureRequest) -> Callable[[K], type[View]]: """Combine a mixin and View for a test.""" mixin_request = request.node.get_closest_marker("mixin") if not mixin_request: @@ -48,7 +48,7 @@ def mixin_view_factory(request: pytest.FixtureRequest) -> Callable: mixin_name = mixin_request.args[0] mixin_class = getattr(mixins, mixin_name) - def mixin_view(**kwargs: dict) -> Type[View]: + def mixin_view(**kwargs: dict[str, HttpResponse]) -> type[View]: """Mixed-in view generator.""" default_functions: dict = { "get": lambda s, r, *a, **k: HttpResponse("django-brackets"), @@ -61,10 +61,12 @@ def mixin_view(**kwargs: dict) -> Type[View]: @pytest.fixture(name="single_object_view") -def single_object_view_factory(mixin_view: Callable) -> Callable: +def single_object_view_factory( + mixin_view: Callable, +) -> Callable[[K], type[SingleObjectMixin]]: """Fixture for a view with the `SingleObjectMixin`.""" - def _view(**kwargs: K) -> Type[SingleObjectMixin]: + def _view(**kwargs: K) -> type[SingleObjectMixin]: """Return a mixin view with the `SingleObjectMixin`.""" return type( "SingleObjectView", @@ -80,7 +82,7 @@ def _view(**kwargs: K) -> Type[SingleObjectMixin]: def multiple_object_view_factory(mixin_view: Callable) -> Callable: """Fixture for a view with the `MultipleObjectMixin`.""" - def _view(**kwargs: K) -> Type[MultipleObjectMixin]: + def _view(**kwargs: K) -> MultipleObjectMixin[Any]: """Return a mixin view with the `MultipleObjectMixin`.""" return type( "MultipleObjectView", @@ -96,14 +98,14 @@ def _view(**kwargs: K) -> Type[MultipleObjectMixin]: def form_view_factory(mixin_view: Callable) -> Callable: """Fixture for a view with the `FormMixin`.""" - def _view(**kwargs: K) -> Type[BaseFormView]: + def _view(**kwargs: K) -> BaseFormView[Any]: """Return a view with the `FormMixin` mixin.""" return type( "FormView", (mixin_view(), BaseFormView), { + "fields": "__all__", "http_method_names": ["get", "post"], - # "post": lambda s, r, *a, **k: HttpResponse("post"), }, **kwargs, ) @@ -115,15 +117,16 @@ def _view(**kwargs: K) -> Type[BaseFormView]: def model_form_view_factory(mixin_view: Callable) -> Callable: """Fixture for a view with the `ModelFormMixin`.""" - def _view(**kwargs: K) -> Type[ModelFormMixin]: + def _view(**kwargs: K) -> ModelFormMixin[Any, Any]: """Return a view with the `ModelFormMixin` mixin.""" return type( "FormView", (mixin_view(), ModelFormMixin), { + "fields": "__all__", + "http_method_names": ["get", "post"], "model": Article, "object": None, - "http_method_names": ["get", "post"], "post": lambda s, r, *a, **k: HttpResponse("post"), }, **kwargs, @@ -136,10 +139,10 @@ def _view(**kwargs: K) -> Type[ModelFormMixin]: def form_class_factory() -> Callable: """Generate a new form class with given kwargs.""" - def _form(**kwargs: K) -> Type[forms.Form]: + def _form(**kwargs: K) -> type[Form]: """Return a new form class.""" - class MixinForm(forms.Form): + class MixinForm(Form): class Meta: fields = "__all__" @@ -150,16 +153,19 @@ class Meta: return _form +T_MF = TypeVar("T_MF", bound=ModelForm) + + @pytest.fixture(name="model_form_class") -def model_form_class_factory() -> Callable: +def model_form_class_factory() -> Callable[[K], T_MF]: """Generate a new model form class with given kwargs.""" - def _form(**kwargs: K) -> Type[forms.ModelForm]: + def _form(**kwargs: K) -> T_MF: """Return a new model form class.""" - class MixinModelForm(forms.ModelForm): + class MixinModelForm(ModelForm): class Meta: - fields = [k for k in kwargs] + fields: ClassVar = [k for k in kwargs] model = Article for k, v in kwargs.items(): diff --git a/tests/mixins/test_access.py b/tests/mixins/test_access.py index 25811f1..2ebb521 100644 --- a/tests/mixins/test_access.py +++ b/tests/mixins/test_access.py @@ -7,7 +7,7 @@ from django.contrib.auth.models import Group, Permission from django.contrib.sessions.backends.db import SessionStore from django.core.exceptions import ImproperlyConfigured -from django.http import HttpResponse, HttpResponseBadRequest +from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect from django.utils.timezone import now @@ -79,25 +79,22 @@ def test_attribute_error(self, mixin_view, rf): class TestPassOrRedirectMixin: """Tests for the `PassOrRedirectMixin`.""" - def test_handle_test_failure_unauthenticated(self, mixin_view, rf, user): - """A failed request is redirected.""" - view = mixin_view( - redirect_url="/login", - ) + def test_handle_test_failure_no_redirect(self, mixin_view, rf): + """A failed request raises an exception.""" + view = mixin_view(redirect_url="/login", redirect_unauthenticated_users=False) request = rf.get("/secret") request.user = None - response = view(request=request).handle_test_failure() - assert response.status_code == 302 + response = view(request=request).handle_dispatch_test_failure() + assert isinstance(response, HttpResponseBadRequest) - def test_handle_test_failure_no_redirect(self, mixin_view, rf, user): + def test_handle_test_failure_redirect_users(self, mixin_view, rf): """A failed request raises an exception.""" - view = mixin_view(redirect_url="/login") + view = mixin_view(redirect_url="/login", redirect_unauthenticated_users=True) request = rf.get("/secret") - request.user = user() - - response = view(request=request).handle_test_failure() - assert isinstance(response, HttpResponseBadRequest) + request.user = None + response = view(request=request).handle_dispatch_test_failure() + assert isinstance(response, HttpResponseRedirect) @pytest.mark.mixin("SuperuserRequiredMixin") diff --git a/tests/mixins/test_form_views.py b/tests/mixins/test_form_views.py index 4b2eb90..9ba1a7a 100644 --- a/tests/mixins/test_form_views.py +++ b/tests/mixins/test_form_views.py @@ -1,5 +1,6 @@ -"""Tests relating to the form mixins.""" +"""Tests relating to the form view mixins.""" from unittest.mock import patch + import pytest from django import forms from django.core.exceptions import ImproperlyConfigured @@ -7,6 +8,7 @@ from pytest_lazyfixture import lazy_fixture as lazy from brackets import mixins +from brackets.exceptions import BracketsConfigurationError from tests.project.models import Article @@ -70,6 +72,16 @@ def test_csrf_exempt(self, form, view, rf): class TestMultipleFormsMixin: """Tests related to the MultipleFormsMixin.""" + def test_extra_context(self, form_view, form_class, rf): + """A view can take extra context.""" + request = rf.get("/") + view_class = form_view()( + extra_context={"foo": "bar"}, + form_classes={"one": form_class()}, + request=request, + ) + assert view_class.get_context_data()["foo"] == "bar" + def test_missing_form_classes(self, form_view): """A view with no instances or initials should fail.""" view_class = form_view() @@ -175,9 +187,8 @@ def test_not_implemented(self, view, method): def test_get_instance_improperly_configured(self, form_view, instances): """An improperly configured view raises an exception.""" view = form_view()(form_instances=instances) - with pytest.raises(ImproperlyConfigured) as exc: + with pytest.raises(BracketsConfigurationError): view.get_instance("django-brackets") - assert "instance" in exc def test_instance_found(self, form_view): """If a view is asked for a provided instance, it should be provided.""" @@ -185,20 +196,18 @@ def test_instance_found(self, form_view): view = view(form_instances={"db": "bd"}) assert view.get_instance("db") == "bd" - @pytest.mark.parametrize("initials", [None, "django-brackets"]) - def test_get_initial_improperly_configured(self, form_view, initials): + def test_get_initial_improperly_configured(self, form_view): """An improperly configured view raises an exception.""" - view = form_view()(form_initial_values=initials) - with pytest.raises(ImproperlyConfigured): + view = form_view()(form_initial_values="django-brackets") + with pytest.raises(BracketsConfigurationError): view.get_initial("django-brackets") def test_get_initial_keyerror(self, form_view): """If the initial doesn't exist, a blank dict is returned.""" view = form_view()(form_initial_values={"foo": "bar"}) - initial = view.get_initial("bar") - assert initial == {} + assert view.get_initial("bar") == {} - @pytest.mark.django_db + @pytest.mark.django_db() def test_instance_in_form_kwargs(self, model_form_view, model_form_class, rf): """Instances should appear in ModelForm form kwargs.""" request = rf.get("/") @@ -211,7 +220,7 @@ def test_instance_in_form_kwargs(self, model_form_view, model_form_class, rf): ) assert view.get_form_kwargs("foo")["instance"] == instance - @pytest.mark.parametrize(("valid",), [(True,), (False,)]) + @pytest.mark.parametrize(("valid",), [(True,), (False,)]) # noqa: PT006 @patch( "brackets.mixins.MultipleFormsMixin.forms_invalid", return_value=HttpResponse() ) diff --git a/tests/mixins/test_http.py b/tests/mixins/test_http.py index 51e2455..3d4a77b 100644 --- a/tests/mixins/test_http.py +++ b/tests/mixins/test_http.py @@ -1,14 +1,23 @@ +"""Tests related to the HTTP mixins.""" import pytest from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponse from django.views.generic import View +from brackets.exceptions import BracketsConfigurationError -@pytest.fixture + +@pytest.fixture() def all_verbs_view(mixin_view): + """Fixture for a view with the AllVerbsMixin.""" + def view(**kwargs): - out_class = type("AllVerbsView", (mixin_view(), View), kwargs) - out_class.all = lambda self, request: HttpResponse("OK") + """Create a view that responds 'OK' to all HTTP verbs.""" + out_class: type[View] = type( + "AllVerbsView", + (mixin_view(all=lambda s, r: HttpResponse("OK")), View), + kwargs, + ) return out_class return view @@ -16,33 +25,45 @@ def view(**kwargs): @pytest.mark.mixin("AllVerbsMixin") class TestAllVerbs: + """Tests related to the AllVerbsMixin.""" + @pytest.mark.parametrize( "verb", ["get", "post", "put", "patch", "delete", "head", "options", "trace"], ) def test_verbs(self, verb, all_verbs_view, rf): + """All HTTP verbs should be handled by the mixin's method.""" request = getattr(rf, verb)("/") response = all_verbs_view().as_view()(request) assert response.status_code == 200 def test_undefined_handler(self, mixin_view, rf): + """If the handler is not defined, raise an exception.""" with pytest.raises(NotImplementedError): mixin_view().as_view()(rf.get("/")) def test_missing_handler(self, all_verbs_view, rf): + """If the handler is None, raise an exception.""" view = all_verbs_view() view.all_verb_handler = None with pytest.raises(ImproperlyConfigured): view.as_view()(rf.get("/")) -@pytest.fixture +@pytest.fixture() def cache_view(mixin_view): + """Fixture for a view with the CacheControlMixin.""" + def view(**kwargs): - out_class = type("CacheControlView", (mixin_view(), View), kwargs) - out_class.cache_control_max_age = 120 - out_class.cache_control_no_cache = 120 - out_class.get = lambda self, request: HttpResponse("OK") + """Create a cache-controlled view.""" + out_class: type[View] = type( + "CacheControlView", + ( + mixin_view(), + View, + ), + kwargs, + ) return out_class return view @@ -50,15 +71,25 @@ def view(**kwargs): @pytest.mark.mixin("CacheControlMixin") class TestCacheControl: + """Tests related to the CacheControlMixin.""" + def test_cache_control(self, cache_view, rf): - response = cache_view().as_view()(rf.get("/")) + """The CacheControlMixin should apply cache control headers.""" + response = cache_view( + cache_control_max_age=120, + cache_control_no_cache=120, + get=lambda s, r: HttpResponse("OK"), + ).as_view()(rf.get("/")) assert "max-age=120" in response["Cache-Control"] assert "no-cache" in response["Cache-Control"] @pytest.mark.mixin("HeaderMixin") class TestHeader: + """Tests related to the HeaderMixin.""" + def test_headers(self, mixin_view, rf): + """Provided headers should be added to the response.""" view = mixin_view( headers={"X-Test": "YES"}, ) @@ -66,12 +97,19 @@ def test_headers(self, mixin_view, rf): assert response["X-Test"] == "YES" def test_headers_unset(self, mixin_view, rf): + """If no headers are provided, nothing should be added.""" view = mixin_view() response = view.as_view()(rf.get("/")) with pytest.raises(KeyError): response["X-Test"] + def test_headers_empty(self, mixin_view, rf): + """If no headers are provided, nothing should be added.""" + view = mixin_view(headers={}) + with pytest.raises(BracketsConfigurationError): + view.as_view()(rf.get("/")) + def test_request_headers(self, mixin_view, rf): """Headers coming in on a request shouldn't come out on a response.""" _resp = HttpResponse("OK", headers={"Age": 120}) @@ -84,7 +122,10 @@ def test_request_headers(self, mixin_view, rf): @pytest.mark.mixin("NeverCacheMixin") class TestNeverCache: + """Tests related to the NeverCacheMixin.""" + def test_never_cache(self, mixin_view, rf): + """A NeverCacheMixin view should include headers to avoid being cached.""" response = mixin_view().as_view()(rf.get("/")) assert ( response["Cache-Control"] diff --git a/tests/mixins/test_misc.py b/tests/mixins/test_misc.py deleted file mode 100644 index 27f02ac..0000000 --- a/tests/mixins/test_misc.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Tests related to the miscellaneous mixins.""" - -from typing import Type - -import pytest -from django.core.exceptions import ImproperlyConfigured -from django.views.generic import TemplateView - - -@pytest.fixture() -def static_view(mixin_view): - """Fixture for a TemplateView with the StaticContextMixin.""" - - def _view(**kwargs) -> Type[TemplateView]: - """Return a mixin view with the `StaticContextMixin`.""" - kwargs.update({"template_name": "test.html"}) - return type( - "StaticView", - (mixin_view(), TemplateView), - kwargs, - ) - - return _view - - -@pytest.mark.mixin("StaticContextMixin") -class TestStaticContext: - """Tests related to the `StaticContextMixin`.""" - - def test_static_context_attribute(self, static_view): - """Test that `static_context` is returned.""" - view = static_view(static_context={"django": "brackets"}) - assert view().get_static_context() == {"django": "brackets"} - - def test_static_context_method(self, static_view): - """Test that `get_static_context` is returned.""" - view = static_view(get_static_context=lambda x: {"django": "brackets"}) - assert view().get_static_context() == {"django": "brackets"} - - def test_static_context_missing(self, static_view): - """With no `static_context` attribute or method, raise an exception.""" - with pytest.raises(ImproperlyConfigured): - static_view()().get_static_context() - - def test_static_context_in_context(self, static_view): - """Test that `static_context` is in the view's context.""" - view = static_view(static_context={"django": "brackets"}) - assert view().get_context_data()["django"] == "brackets" - - def test_static_context_not_dict(self, static_view): - """With a non-dict `static_context`, raise an exception.""" - view = static_view(static_context="django") - with pytest.raises(ImproperlyConfigured): - view().get_static_context() diff --git a/tests/mixins/test_queries.py b/tests/mixins/test_queries.py index 3964c10..337da96 100644 --- a/tests/mixins/test_queries.py +++ b/tests/mixins/test_queries.py @@ -102,6 +102,16 @@ def test_queryset_ordering(self, multiple_object_view, rf): ) assert view.get_queryset().query.order_by == ("author",) + def test_queryset_ordering_with_string(self, multiple_object_view, rf): + """No querystring arguments should use the defaults.""" + request = rf.get("/") + view = multiple_object_view()( + orderable_field_default="author", + orderable_fields="author", + request=request, + ) + assert view.get_queryset().query.order_by == ("author",) + def test_queryset_ordering_with_request(self, multiple_object_view, rf): """Querystring arguments should override the queryset's ordering.""" request = rf.get("/?order_dir=desc") @@ -126,7 +136,7 @@ def test_orderable_fields_exceptions(self, method, multiple_object_view): with pytest.raises(ImproperlyConfigured): getattr(view, method)() - @pytest.mark.django_db + @pytest.mark.django_db() def test_basic_queryset_returned(self, user, multiple_object_view, rf): """Test that the correct queryset is returned.""" request = rf.get("/?order_by=publish_date&order_dir=desc") diff --git a/tests/mixins/test_redirects.py b/tests/mixins/test_redirects.py index da2bbc4..c5728df 100644 --- a/tests/mixins/test_redirects.py +++ b/tests/mixins/test_redirects.py @@ -31,25 +31,6 @@ def test_get_redirected(self, mixin_view, rf): class TestRedirectToLogin: """Tests related to the `RedirectToLoginMixin`.""" - def test_get_redirect_url(self, mixin_view): - """Views must have `redirect_view` defined.""" - view = mixin_view(redirect_url="/") - assert view().get_redirect_url() == "/" - - def test_no_redirect_url(self, mixin_view): - """An empty or missing `redirect_view` raises an exception.""" - view = mixin_view(redirect_url="") - with pytest.raises(ImproperlyConfigured): - view().get_redirect_url() - - def test_get_redirected(self, mixin_view, rf): - """Views redirect requests.""" - view = mixin_view(redirect_url="/") - request = rf.get("/secret/") - response = view(request=request).redirect() - assert response.status_code == 302 - assert isinstance(response, HttpResponseRedirect) - def test_get_login_url(self, mixin_view): """A provided `login_url` is returned.""" view = mixin_view(login_url="/login") @@ -61,3 +42,9 @@ def test_get_login_url_exception(self, mixin_view, settings): view = mixin_view(login_url=None) with pytest.raises(ImproperlyConfigured): view().get_login_url() + + def test_get_redirected(self, mixin_view, rf): + """Views with can be redirected.""" + view = mixin_view(redirect_url="/", request=rf.get("/")) + response = view().redirect() + assert response.status_code == 302 diff --git a/tests/mixins/test_rest_framework.py b/tests/mixins/test_rest_framework.py index 4c76868..162787a 100644 --- a/tests/mixins/test_rest_framework.py +++ b/tests/mixins/test_rest_framework.py @@ -1,47 +1,63 @@ +"""Tests related to the Django REST Framework-related mixins.""" +from typing import Any, ClassVar + import pytest from django.core.exceptions import ImproperlyConfigured from rest_framework.generics import GenericAPIView +from rest_framework.serializers import Serializer + +from brackets.mixins import MultipleSerializersMixin -from brackets import mixins +SC = dict[str, type[Serializer[Any]]] class TestMultipleSerializers: """Tests related to the `MultipleSerializersMixin`.""" + class _TestSerializer(Serializer[Any]): + """Test serializer.""" + def test_get_serializer_class(self, rf): """Views are able to return a specific serializer class.""" - class _View(mixins.MultipleSerializersMixin, GenericAPIView): - serializer_classes = {"get": "pass", "post": "fail"} + class _View(MultipleSerializersMixin, GenericAPIView): + serializer_classes: ClassVar[SC] = { + "get": self._TestSerializer, + "post": self._TestSerializer, + } request = rf.get("/") view = _View() view.setup(request) - assert view.get_serializer_class() == "pass" + assert view.get_serializer_class() == self._TestSerializer def test_get_serializer_class_missing(self): """Views without `serializer_classes` raise an exception.""" - class _View(mixins.MultipleSerializersMixin, GenericAPIView): + class _View(MultipleSerializersMixin, GenericAPIView): pass with pytest.raises(ImproperlyConfigured): _View().get_serializer_class() - def test_get_serializer_class_invalid(self): + def test_get_serializer_class_invalid(self, rf): """Views with invalid `serializer_classes` raise an exception.""" - class _View(mixins.MultipleSerializersMixin, GenericAPIView): - serializer_classes = "test" + class _View(MultipleSerializersMixin, GenericAPIView): + serializer_classes = "test" # type: ignore - with pytest.raises(ImproperlyConfigured): - _View().get_serializer_class() + request = rf.get("/") + view = _View() + view.setup(request) + + with pytest.raises(TypeError): + view.get_serializer_class() def test_get_serializer_class_invalid_method(self, rf): """Views without a serializer for the method raise an exception.""" - class _View(mixins.MultipleSerializersMixin, GenericAPIView): - serializer_classes = {"post": "pass"} + class _View(MultipleSerializersMixin, GenericAPIView): + serializer_classes: ClassVar[SC] = {"post": self._TestSerializer} request = rf.get("/") view = _View() @@ -49,3 +65,21 @@ class _View(mixins.MultipleSerializersMixin, GenericAPIView): with pytest.raises(KeyError): view.get_serializer_class() + + def test_get_original_serializer(self, rf): + """Views are able to return the original serializer class.""" + + class _View(MultipleSerializersMixin, GenericAPIView): + serializer_class = self._TestSerializer + serializer_classes: ClassVar[SC] = { + "get": Serializer, + "post": Serializer, + } + + def get_serializer_classes(self): + return [] + + request = rf.get("/") + view = _View() + view.setup(request) + assert view.get_serializer_class() == self._TestSerializer diff --git a/tests/project/forms.py b/tests/project/forms.py index 08da28d..f9b6c6f 100644 --- a/tests/project/forms.py +++ b/tests/project/forms.py @@ -1,3 +1,5 @@ +from typing import Any, ClassVar + from django import forms from brackets import mixins @@ -6,14 +8,14 @@ class FormWithUserKwarg(mixins.UserFormMixin, forms.Form): - """This form will get a `user` kwarg""" + """Form with a user kwarg.""" field1 = forms.CharField() -class ArticleForm(forms.ModelForm): - """This form represents an Article""" +class ArticleForm(forms.ModelForm[Any]): + """Form for an Article.""" - class Meta: + class Meta: # noqa: D106 model = Article - fields = ["author", "title", "body", "slug"] + fields: ClassVar[list[str]] = ["author", "title", "body", "slug"] diff --git a/tests/project/helpers.py b/tests/project/helpers.py index 504d0c6..4537ce0 100644 --- a/tests/project/helpers.py +++ b/tests/project/helpers.py @@ -1,65 +1,13 @@ -from django import test -from django.contrib.auth.models import AnonymousUser -from django.core.serializers.json import DjangoJSONEncoder - - -class TestViewHelper: - """ - Helper class for unit-testing class based views. - """ - - view_class = None - request_factory_class = test.RequestFactory - - def setUp(self): - super().setUp() - self.factory = self.request_factory_class() +from typing import Any - def build_request(self, method="GET", path="/test/", user=None, **kwargs): - """ - Creates a request using request factory. - """ - fn = getattr(self.factory, method.lower()) - if user is None: - user = AnonymousUser() - - req = fn(path, **kwargs) - req.user = user - return req - - def build_view( - self, request, args=None, kwargs=None, view_class=None, **viewkwargs - ): - """ - Creates a `view_class` view instance. - """ - if not args: - args = () - if not kwargs: - kwargs = {} - if view_class is None: - view_class = self.view_class - - return view_class(request=request, args=args, kwargs=kwargs, **viewkwargs) - - def dispatch_view( - self, request, args=None, kwargs=None, view_class=None, **viewkwargs - ): - """ - Creates and dispatches `view_class` view. - """ - view = self.build_view(request, args, kwargs, view_class, **viewkwargs) - return view.dispatch(request, *view.args, **view.kwargs) +from django.core.serializers.json import DjangoJSONEncoder class SetJSONEncoder(DjangoJSONEncoder): - """ - A custom JSONEncoder extending `DjangoJSONEncoder` to handle serialization - of `set`. - """ + """A custom JSON Encoder for `set`.""" - def default(self, obj): - """Control default methods of encoding data""" + def default(self, obj: Any) -> Any: # noqa ANN001 + """Control default methods of encoding data.""" if isinstance(obj, set): return list(obj) return super(DjangoJSONEncoder, self).default(obj) diff --git a/tests/project/models.py b/tests/project/models.py index 7c7dd75..a7de42f 100644 --- a/tests/project/models.py +++ b/tests/project/models.py @@ -17,10 +17,11 @@ class Article(models.Model): body = models.TextField() slug = models.SlugField(blank=True) - class Meta: + class Meta: # noqa: D106 app_label = "project" def __str__(self) -> str: + """Return the string version of an Article.""" return f"_{self.title}_ by {self.author}." @@ -34,14 +35,15 @@ class CanonicalArticle(models.Model): body = models.TextField() slug = models.SlugField(blank=True) - class Meta: + class Meta: # noqa: D106 app_label = "project" + def __str__(self) -> str: + """Return the string version of a CanonicalArticle.""" + return f"_{self.title}_ by {self.author}." + def get_canonical_slug(self) -> str: """Return the slug of record.""" if self.author: return f"{self.author.username}-{self.slug}" return f"unauthored-{self.slug}" - - def __str__(self) -> str: - return f"_{self.title}_ by {self.author}." diff --git a/tests/project/settings.py b/tests/project/settings.py index 8a77b54..aeeaad2 100644 --- a/tests/project/settings.py +++ b/tests/project/settings.py @@ -1,13 +1,22 @@ +import contextlib + from django.conf.global_settings import * # noqa: F401, F403 from django.contrib.messages import constants as message_constants # Settings deleted to prevent RemovedInDjango 5.0 warnings -del CSRF_COOKIE_MASKED -del DEFAULT_FILE_STORAGE -del USE_L10N -del PASSWORD_RESET_TIMEOUT # pyright: ignore[reportUndefinedVariable] -del STATICFILES_STORAGE -del USE_DEPRECATED_PYTZ +REMOVED_SETTINGS = [ + "CSRF_COOKIE_MASKED", + "DEFAULT_FILE_STORAGE", + "DEFAULT_HASHING_ALGORITHM", + "FORMS_URLFIELD_ASSUME_HTTPS", + "PASSWORD_RESET_TIMEOUT_DAYS", + "STATICFILES_STORAGE", + "USE_DEPRECATED_PYTZ", + "USE_L10N", +] +for setting in REMOVED_SETTINGS: + with contextlib.suppress(KeyError): + del globals()[setting] DEBUG = False TEMPLATE_DEBUG = DEBUG diff --git a/tests/project/urls.py b/tests/project/urls.py index 7ced167..5201a55 100644 --- a/tests/project/urls.py +++ b/tests/project/urls.py @@ -1,3 +1,4 @@ """Test project URLs.""" +from django.urls import URLPattern -urlpatterns = [] +urlpatterns: list[URLPattern] = [] diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..493efba --- /dev/null +++ b/tox.ini @@ -0,0 +1,18 @@ +[tox] +requires = + tox>=4 +env_list = py{310,311,312}-django{42,50} + +[testenv] +description = run unit tests +deps = + pytest>=7 + pytest-cov + pytest-django + pytest-lazy-fixture + pytest-randomly + pytest-xdist + django42: Django~=4.2 + django50: Django~=5.0 +commands = + pytest {posargs:tests} From 82913897faa055fd74a5672a09abccf79bfb9ed3 Mon Sep 17 00:00:00 2001 From: Kenneth Love <11908+kennethlove@users.noreply.github.com> Date: Thu, 18 Jan 2024 16:10:43 -0800 Subject: [PATCH 2/2] Create python-publish.yml --- .github/workflows/python-publish.yml | 39 ++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/python-publish.yml diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..bdaab28 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,39 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Upload Python Package + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }}