diff --git a/.gitignore b/.gitignore index 63d548a..28daf01 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ *.sw[po] .cache .coverage +.python-version .tox dist build diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0c63bb6..c70afbe 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.1 + rev: v0.5.0 hooks: # Run the linter - id: ruff @@ -22,6 +22,6 @@ repos: # Run the formatter - id: ruff-format - repo: https://github.com/tox-dev/pyproject-fmt - rev: 1.8.0 + rev: 2.1.3 hooks: - id: pyproject-fmt diff --git a/CHANGES.md b/CHANGES.md index bc9f57e..77747e3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,8 +2,9 @@ CHANGES ======= Unreleased -=========== +========== - Add type hints. ([#228](https://github.com/mozilla/django-csp/pull/228)) +- Expand ruff configuration and move into pyproject.toml [[#234](https://github.com/mozilla/django-csp/pull/234)] 4.0b1 ===== diff --git a/csp/checks.py b/csp/checks.py index 9c15b0a..0096b0e 100644 --- a/csp/checks.py +++ b/csp/checks.py @@ -1,6 +1,8 @@ from __future__ import annotations + import pprint -from typing import Dict, Tuple, Any, Optional, Sequence, TYPE_CHECKING, List +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any from django.conf import settings from django.core.checks import Error @@ -45,9 +47,9 @@ ] -def migrate_settings() -> Tuple[Dict[str, Any], bool]: +def migrate_settings() -> tuple[dict[str, Any], bool]: # This function is used to migrate settings from the old format to the new format. - config: Dict[str, Any] = { + config: dict[str, Any] = { "DIRECTIVES": {}, } @@ -75,7 +77,7 @@ def migrate_settings() -> Tuple[Dict[str, Any], bool]: return config, REPORT_ONLY -def check_django_csp_lt_4_0(app_configs: Optional[Sequence[AppConfig]], **kwargs: Any) -> List[Error]: +def check_django_csp_lt_4_0(app_configs: Sequence[AppConfig] | None, **kwargs: Any) -> list[Error]: check_settings = OUTDATED_SETTINGS + ["CSP_REPORT_ONLY", "CSP_EXCLUDE_URL_PREFIXES", "CSP_REPORT_PERCENTAGE"] if any(hasattr(settings, setting) for setting in check_settings): # Try to build the new config. diff --git a/csp/context_processors.py b/csp/context_processors.py index 4c34e0a..b5eec54 100644 --- a/csp/context_processors.py +++ b/csp/context_processors.py @@ -1,11 +1,12 @@ from __future__ import annotations -from typing import Dict, Literal, TYPE_CHECKING + +from typing import TYPE_CHECKING, Literal if TYPE_CHECKING: from django.http import HttpRequest -def nonce(request: HttpRequest) -> Dict[Literal["CSP_NONCE"], str]: - nonce = request.csp_nonce if hasattr(request, "csp_nonce") else "" +def nonce(request: HttpRequest) -> dict[Literal["CSP_NONCE"], str]: + nonce = getattr(request, "csp_nonce", "") return {"CSP_NONCE": nonce} diff --git a/csp/contrib/rate_limiting.py b/csp/contrib/rate_limiting.py index 3645a47..9633a87 100644 --- a/csp/contrib/rate_limiting.py +++ b/csp/contrib/rate_limiting.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING + import random +from typing import TYPE_CHECKING from django.conf import settings diff --git a/csp/decorators.py b/csp/decorators.py index bcac3b6..b330b6a 100644 --- a/csp/decorators.py +++ b/csp/decorators.py @@ -1,7 +1,7 @@ from __future__ import annotations from functools import wraps -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Callable if TYPE_CHECKING: from django.http import HttpRequest, HttpResponseBase @@ -11,7 +11,7 @@ _VIEW_DECORATOR_T = Callable[[_VIEW_T], _VIEW_T] -def csp_exempt(REPORT_ONLY: Optional[bool] = None) -> _VIEW_DECORATOR_T: +def csp_exempt(REPORT_ONLY: bool | None = None) -> _VIEW_DECORATOR_T: if callable(REPORT_ONLY): raise RuntimeError( "Incompatible `csp_exempt` decorator usage. This decorator now requires arguments, " @@ -42,7 +42,7 @@ def _wrapped(*a: Any, **kw: Any) -> HttpResponseBase: ) -def csp_update(config: Optional[Dict[str, Any]] = None, REPORT_ONLY: bool = False, **kwargs: Any) -> _VIEW_DECORATOR_T: +def csp_update(config: dict[str, Any] | None = None, REPORT_ONLY: bool = False, **kwargs: Any) -> _VIEW_DECORATOR_T: if config is None and kwargs: raise RuntimeError(DECORATOR_DEPRECATION_ERROR.format(fname="csp_update")) @@ -61,7 +61,7 @@ def _wrapped(*a: Any, **kw: Any) -> HttpResponseBase: return decorator -def csp_replace(config: Optional[Dict[str, Any]] = None, REPORT_ONLY: bool = False, **kwargs: Any) -> _VIEW_DECORATOR_T: +def csp_replace(config: dict[str, Any] | None = None, REPORT_ONLY: bool = False, **kwargs: Any) -> _VIEW_DECORATOR_T: if config is None and kwargs: raise RuntimeError(DECORATOR_DEPRECATION_ERROR.format(fname="csp_replace")) @@ -80,12 +80,12 @@ def _wrapped(*a: Any, **kw: Any) -> HttpResponseBase: return decorator -def csp(config: Optional[Dict[str, Any]] = None, REPORT_ONLY: bool = False, **kwargs: Any) -> _VIEW_DECORATOR_T: +def csp(config: dict[str, Any] | None = None, REPORT_ONLY: bool = False, **kwargs: Any) -> _VIEW_DECORATOR_T: if config is None and kwargs: raise RuntimeError(DECORATOR_DEPRECATION_ERROR.format(fname="csp")) if config is None: - processed_config: Dict[str, List[Any]] = {} + processed_config: dict[str, list[Any]] = {} else: processed_config = {k: [v] if isinstance(v, str) else v for k, v in config.items()} diff --git a/csp/extensions/__init__.py b/csp/extensions/__init__.py index 45c4de4..8ddc782 100644 --- a/csp/extensions/__init__.py +++ b/csp/extensions/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations -from typing import Callable, TYPE_CHECKING, Any + +from typing import TYPE_CHECKING, Any, Callable from jinja2 import nodes from jinja2.ext import Extension diff --git a/csp/middleware.py b/csp/middleware.py index 03cc745..ff58c61 100644 --- a/csp/middleware.py +++ b/csp/middleware.py @@ -1,4 +1,5 @@ from __future__ import annotations + import base64 import http.client as http_client import os diff --git a/csp/templatetags/csp.py b/csp/templatetags/csp.py index a28bfc1..415bfcf 100644 --- a/csp/templatetags/csp.py +++ b/csp/templatetags/csp.py @@ -1,12 +1,14 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional + +from typing import TYPE_CHECKING + from django import template from django.template.base import token_kwargs from csp.utils import build_script_tag if TYPE_CHECKING: - from django.template.base import NodeList, FilterExpression, Token, Parser + from django.template.base import FilterExpression, NodeList, Parser, Token from django.template.context import Context register = template.Library() @@ -18,7 +20,7 @@ def _unquote(s: str) -> str: @register.tag(name="script") -def script(parser: Parser, token: Token) -> "NonceScriptNode": +def script(parser: Parser, token: Token) -> NonceScriptNode: # Parse out any keyword args token_args = token.split_contents() kwargs = token_kwargs(token_args[1:], parser) @@ -36,7 +38,7 @@ def __init__(self, nodelist: NodeList, **kwargs: FilterExpression) -> None: for k, v in kwargs.items(): self.script_attrs[k] = self._get_token_value(v) - def _get_token_value(self, t: FilterExpression) -> Optional[str]: + def _get_token_value(self, t: FilterExpression) -> str | None: if hasattr(t, "token") and t.token: return _unquote(t.token) return None diff --git a/csp/tests/environment.py b/csp/tests/environment.py index a5d188d..e53a562 100644 --- a/csp/tests/environment.py +++ b/csp/tests/environment.py @@ -1,6 +1,7 @@ -from jinja2 import Environment from typing import Any +from jinja2 import Environment + def environment(**options: Any) -> Environment: env = Environment(**options) diff --git a/csp/tests/settings.py b/csp/tests/settings.py index ffad00d..8a0ea7e 100644 --- a/csp/tests/settings.py +++ b/csp/tests/settings.py @@ -1,6 +1,5 @@ from csp.constants import NONCE, SELF - CONTENT_SECURITY_POLICY = { "DIRECTIVES": { "default-src": [SELF, NONCE], diff --git a/csp/tests/test_decorators.py b/csp/tests/test_decorators.py index 1a11dfd..7e29b69 100644 --- a/csp/tests/test_decorators.py +++ b/csp/tests/test_decorators.py @@ -1,11 +1,13 @@ from __future__ import annotations + from typing import TYPE_CHECKING -import pytest from django.http import HttpResponse from django.test import RequestFactory from django.test.utils import override_settings +import pytest + from csp.constants import HEADER, HEADER_REPORT_ONLY, NONCE from csp.decorators import csp, csp_exempt, csp_replace, csp_update from csp.middleware import CSPMiddleware diff --git a/csp/tests/test_utils.py b/csp/tests/test_utils.py index d76f49a..74d5c4f 100644 --- a/csp/tests/test_utils.py +++ b/csp/tests/test_utils.py @@ -2,7 +2,7 @@ from django.utils.functional import lazy from csp.constants import NONCE, NONE, SELF -from csp.utils import build_policy, default_config, DEFAULT_DIRECTIVES +from csp.utils import DEFAULT_DIRECTIVES, build_policy, default_config def policy_eq(a: str, b: str) -> None: diff --git a/csp/tests/utils.py b/csp/tests/utils.py index 36512ca..dd139c3 100644 --- a/csp/tests/utils.py +++ b/csp/tests/utils.py @@ -1,6 +1,7 @@ from __future__ import annotations + from abc import ABC, abstractmethod -from typing import Dict, Optional, TYPE_CHECKING, Callable, Any, Tuple +from typing import TYPE_CHECKING, Any, Callable from django.http import HttpResponse from django.template import Context, Template, engines @@ -13,7 +14,7 @@ from django.http import HttpRequest -def response(*args: Any, headers: Optional[Dict[str, str]] = None, **kwargs: Any) -> Callable[[HttpRequest], HttpResponse]: +def response(*args: Any, headers: dict[str, str] | None = None, **kwargs: Any) -> Callable[[HttpRequest], HttpResponse]: def get_response(req: HttpRequest) -> HttpResponse: response = HttpResponse(*args, **kwargs) if headers: @@ -35,7 +36,7 @@ def assert_template_eq(self, tpl1: str, tpl2: str) -> None: bbb = tpl2.replace("\n", "").replace(" ", "") assert aaa == bbb, f"{aaa} != {bbb}" - def process_templates(self, tpl: str, expected: str) -> Tuple[str, str]: + def process_templates(self, tpl: str, expected: str) -> tuple[str, str]: request = rf.get("/") mw.process_request(request) nonce = getattr(request, "csp_nonce") diff --git a/csp/utils.py b/csp/utils.py index fe35633..378f83c 100644 --- a/csp/utils.py +++ b/csp/utils.py @@ -1,8 +1,10 @@ +from __future__ import annotations + import copy import re from collections import OrderedDict from itertools import chain -from typing import Any, Dict, Optional, Union, Callable +from typing import Any, Callable, Dict from django.conf import settings from django.utils.encoding import force_str @@ -53,7 +55,7 @@ _DIRECTIVES = Dict[str, Any] -def default_config(csp: Optional[_DIRECTIVES]) -> Optional[_DIRECTIVES]: +def default_config(csp: _DIRECTIVES | None) -> _DIRECTIVES | None: if csp is None: return None # Make a copy of the passed in config to avoid mutating it, and also to drop any unknown keys. @@ -64,10 +66,10 @@ def default_config(csp: Optional[_DIRECTIVES]) -> Optional[_DIRECTIVES]: def build_policy( - config: Optional[_DIRECTIVES] = None, - update: Optional[_DIRECTIVES] = None, - replace: Optional[_DIRECTIVES] = None, - nonce: Optional[str] = None, + config: _DIRECTIVES | None = None, + update: _DIRECTIVES | None = None, + replace: _DIRECTIVES | None = None, + nonce: str | None = None, report_only: bool = False, ) -> str: """Builds the policy as a string from the settings.""" @@ -151,7 +153,7 @@ def _bool_attr_mapper(attr_name: str, val: bool) -> str: return "" -def _async_attr_mapper(attr_name: str, val: Union[str, bool]) -> str: +def _async_attr_mapper(attr_name: str, val: str | bool) -> str: """The `async` attribute works slightly different than the other bool attributes. It can be set explicitly to `false` with no surrounding quotes according to the spec.""" @@ -164,7 +166,7 @@ def _async_attr_mapper(attr_name: str, val: Union[str, bool]) -> str: # Allow per-attribute customization of returned string template -SCRIPT_ATTRS: Dict[str, Callable[[str, Any], str]] = OrderedDict() +SCRIPT_ATTRS: dict[str, Callable[[str, Any], str]] = OrderedDict() SCRIPT_ATTRS["nonce"] = _default_attr_mapper SCRIPT_ATTRS["id"] = _default_attr_mapper SCRIPT_ATTRS["src"] = _default_attr_mapper @@ -197,7 +199,7 @@ def _unwrap_script(text: str) -> str: return text -def build_script_tag(content: Optional[str] = None, **kwargs: Any) -> str: +def build_script_tag(content: str | None = None, **kwargs: Any) -> str: data = {} # Iterate all possible script attrs instead of kwargs to make # interpolation as easy as possible below diff --git a/docs/configuration.rst b/docs/configuration.rst index 2980339..c226591 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -77,7 +77,7 @@ a more slightly strict policy and is used to test the policy without breaking th signifies that you do not want any sources for this directive. The ``None`` value is a Python keyword that represents the absence of a value and when used as the value of a directive, it will remove the directive from the policy. - + This is useful when using the ``@csp_replace`` decorator to effectively clear a directive from the base configuration as defined in the settings. For example, if the Django settings the ``frame-ancestors`` directive is set to a list of sources and you want to remove the @@ -124,9 +124,9 @@ policy. The CSP keyword values of ``'self'``, ``'unsafe-inline'``, ``'strict-dynamic'``, etc. must be quoted! e.g.: ``"default-src": ["'self'"]``. Without quotes they will not work as intended. - + New in version 4.0 are CSP keyword constants. Use these to minimize quoting mistakes and typos. - + The following CSP keywords are available: * ``NONE`` = ``"'none'"`` @@ -140,9 +140,9 @@ policy. * ``WASM_UNSAFE_EVAL`` = ``"'wasm-unsafe-eval'"`` Example usage: - + .. code-block:: python - + from csp.constants import SELF, STRICT_DYNAMIC CONTENT_SECURITY_POLICY = { @@ -318,4 +318,4 @@ the :ref:`decorator documentation ` for more details. .. _block-all-mixed-content_mdn: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/block-all-mixed-content .. _plugin_types_mdn: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/plugin-types .. _prefetch_src_mdn: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/prefetch-src -.. _strict-csp: https://csp.withgoogle.com/docs/strict-csp.html \ No newline at end of file +.. _strict-csp: https://csp.withgoogle.com/docs/strict-csp.html diff --git a/docs/migration-guide.rst b/docs/migration-guide.rst index 5320c8f..4c78327 100644 --- a/docs/migration-guide.rst +++ b/docs/migration-guide.rst @@ -106,11 +106,11 @@ The new settings would be: .. note:: If you were using the ``CSP_INCLUDE_NONCE_IN`` setting, this has been removed in the new settings - format. - + format. + **Previously:** You could use the ``CSP_INCLUDE_NONCE_IN`` setting to specify which directives in your Content Security Policy (CSP) should include a nonce. - + **Now:** You can include a nonce in any directive by adding the ``NONCE`` constant from the ``csp.constants`` module to the list of sources for that directive. diff --git a/pyproject.toml b/pyproject.toml index 78ceec9..b092e24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,8 +48,8 @@ optional-dependencies.dev = [ "pytest-cov", "pytest-django", "pytest-ruff", - "Sphinx", - "sphinx_rtd_theme", + "sphinx", + "sphinx-rtd-theme", "tox", "tox-gh-actions", "types-setuptools", @@ -85,13 +85,59 @@ include-package-data = true [tool.setuptools.packages] find = { namespaces = false } +[tool.ruff] +target-version = "py38" +line-length = 150 +indent-width = 4 +extend-exclude = [ + "build", + "dist", + "docs", +] + +# Set what ruff should check for. +# See https://docs.astral.sh//ruff/rules/ for a list of rules. +lint.select = [ + "A", # flake8-builtin errors + "C4", # flake8-comprehensions errors + "DJ", # flake8-django errors + "E", # pycodestyle errors + "F", # pyflakes errors + "I", # import sorting + "Q", # flake8-quotes errors + "T20", # flake8-print errors + "UP", # py-upgrade + "W", # pycodestyle warnings + # "B", # bugbear errors - incompatible with our use of `gettattr` and `setattr`. +] +# Allow unused variables when underscore-prefixed. +lint.dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" +lint.isort.known-first-party = [ + "csp", +] +lint.isort.section-order = [ + "future", + "standard-library", + "django", + "third-party", + "first-party", + "local-folder", +] +lint.isort.sections.django = [ + "django", +] + [tool.pytest.ini_options] addopts = "-vs --tb=short --ruff --ruff-format" DJANGO_SETTINGS_MODULE = "csp.tests.settings" [tool.mypy] -plugins = ["mypy_django_plugin.main"] -exclude = ['^build/lib'] +plugins = [ + "mypy_django_plugin.main", +] +exclude = [ + '^build/lib', +] strict = true [tool.django-stubs] diff --git a/ruff.toml b/ruff.toml deleted file mode 100644 index 42cf5a4..0000000 --- a/ruff.toml +++ /dev/null @@ -1,60 +0,0 @@ -# Exclude a variety of commonly ignored directories. -exclude = [ - ".eggs", - ".git", - ".pyenv", - ".pytest_cache", - ".ruff_cache", - ".tox", - ".vscode", - "build", - "dist", - "node_modules", -] - -line-length = 150 -indent-width = 4 - -# Assume Python 3.11 -target-version = "py311" - -[lint] -# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. -# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or -# McCabe complexity (`C901`) by default. -select = ["E4", "E7", "E9", "F"] -ignore = [] - -# Allow fix for all enabled rules (when `--fix`) is provided. -fixable = ["ALL"] -unfixable = [] - -# Allow unused variables when underscore-prefixed. -dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" - -[format] -# Like Black, use double quotes for strings. -quote-style = "double" - -# Like Black, indent with spaces, rather than tabs. -indent-style = "space" - -# Like Black, respect magic trailing commas. -skip-magic-trailing-comma = false - -# Like Black, automatically detect the appropriate line ending. -line-ending = "auto" - -# Enable auto-formatting of code examples in docstrings. Markdown, -# reStructuredText code/literal blocks and doctests are all supported. -# -# This is currently disabled by default, but it is planned for this -# to be opt-out in the future. -docstring-code-format = false - -# Set the line length limit used when formatting code snippets in -# docstrings. -# -# This only has an effect when the `docstring-code-format` setting is -# enabled. -docstring-code-line-length = "dynamic"