diff --git a/CHANGES b/CHANGES.md
similarity index 97%
rename from CHANGES
rename to CHANGES.md
index ec36bfc..bc9f57e 100644
--- a/CHANGES
+++ b/CHANGES.md
@@ -1,10 +1,12 @@
-=======
CHANGES
=======
-4.x - Unreleased
-================
+Unreleased
+===========
+- Add type hints. ([#228](https://github.com/mozilla/django-csp/pull/228))
+4.0b1
+=====
BACKWARDS INCOMPATIBLE changes:
- Move to dict-based configuration which allows for setting policies for both enforced and
report-only. See the migration guide in the docs for migrating your settings.
diff --git a/csp/apps.py b/csp/apps.py
index caf9f45..38e3103 100644
--- a/csp/apps.py
+++ b/csp/apps.py
@@ -7,5 +7,7 @@
class CspConfig(AppConfig):
name = "csp"
- def ready(self):
- checks.register(check_django_csp_lt_4_0, checks.Tags.security)
+ def ready(self) -> None:
+ # Ignore known issue typeddjango/django-stubs #2232
+ # The overload of CheckRegistry.register as a function is incomplete
+ checks.register(check_django_csp_lt_4_0, checks.Tags.security) # type: ignore
diff --git a/csp/checks.py b/csp/checks.py
index 8a79fa6..9c15b0a 100644
--- a/csp/checks.py
+++ b/csp/checks.py
@@ -1,10 +1,15 @@
+from __future__ import annotations
import pprint
+from typing import Dict, Tuple, Any, Optional, Sequence, TYPE_CHECKING, List
from django.conf import settings
from django.core.checks import Error
from csp.constants import NONCE
+if TYPE_CHECKING:
+ from django.apps.config import AppConfig
+
OUTDATED_SETTINGS = [
"CSP_CHILD_SRC",
@@ -40,21 +45,21 @@
]
-def migrate_settings():
+def migrate_settings() -> Tuple[Dict[str, Any], bool]:
# This function is used to migrate settings from the old format to the new format.
- config = {
+ config: Dict[str, Any] = {
"DIRECTIVES": {},
}
- REPORT_ONLY = False
- if hasattr(settings, "CSP_REPORT_ONLY"):
- REPORT_ONLY = settings.CSP_REPORT_ONLY
+ REPORT_ONLY = getattr(settings, "CSP_REPORT_ONLY", False)
- if hasattr(settings, "CSP_EXCLUDE_URL_PREFIXES"):
- config["EXCLUDE_URL_PREFIXES"] = settings.CSP_EXCLUDE_URL_PREFIXES
+ _EXCLUDE_URL_PREFIXES = getattr(settings, "CSP_EXCLUDE_URL_PREFIXES", None)
+ if _EXCLUDE_URL_PREFIXES is not None:
+ config["EXCLUDE_URL_PREFIXES"] = _EXCLUDE_URL_PREFIXES
- if hasattr(settings, "CSP_REPORT_PERCENTAGE"):
- config["REPORT_PERCENTAGE"] = round(settings.CSP_REPORT_PERCENTAGE * 100)
+ _REPORT_PERCENTAGE = getattr(settings, "CSP_REPORT_PERCENTAGE", None)
+ if _REPORT_PERCENTAGE is not None:
+ config["REPORT_PERCENTAGE"] = round(_REPORT_PERCENTAGE * 100)
include_nonce_in = getattr(settings, "CSP_INCLUDE_NONCE_IN", [])
@@ -70,7 +75,7 @@ def migrate_settings():
return config, REPORT_ONLY
-def check_django_csp_lt_4_0(app_configs, **kwargs):
+def check_django_csp_lt_4_0(app_configs: Optional[Sequence[AppConfig]], **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/constants.py b/csp/constants.py
index b8e08cf..5504339 100644
--- a/csp/constants.py
+++ b/csp/constants.py
@@ -1,3 +1,5 @@
+from typing import Any, Type
+
HEADER = "Content-Security-Policy"
HEADER_REPORT_ONLY = "Content-Security-Policy-Report-Only"
@@ -15,12 +17,12 @@
class Nonce:
_instance = None
- def __new__(cls, *args, **kwargs):
+ def __new__(cls: Type["Nonce"], *args: Any, **kwargs: Any) -> "Nonce":
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
- def __repr__(self):
+ def __repr__(self) -> str:
return "csp.constants.NONCE"
diff --git a/csp/context_processors.py b/csp/context_processors.py
index 666da2c..4c34e0a 100644
--- a/csp/context_processors.py
+++ b/csp/context_processors.py
@@ -1,4 +1,11 @@
-def nonce(request):
+from __future__ import annotations
+from typing import Dict, Literal, TYPE_CHECKING
+
+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 ""
return {"CSP_NONCE": nonce}
diff --git a/csp/contrib/rate_limiting.py b/csp/contrib/rate_limiting.py
index 4cc4f90..3645a47 100644
--- a/csp/contrib/rate_limiting.py
+++ b/csp/contrib/rate_limiting.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+from typing import TYPE_CHECKING
import random
from django.conf import settings
@@ -5,12 +7,15 @@
from csp.middleware import CSPMiddleware
from csp.utils import build_policy
+if TYPE_CHECKING:
+ from django.http import HttpRequest, HttpResponseBase
+
class RateLimitedCSPMiddleware(CSPMiddleware):
"""A CSP middleware that rate-limits the number of violation reports sent
to report-uri by excluding it from some requests."""
- def build_policy(self, request, response):
+ def build_policy(self, request: HttpRequest, response: HttpResponseBase) -> str:
config = getattr(response, "_csp_config", None)
update = getattr(response, "_csp_update", None)
replace = getattr(response, "_csp_replace", {})
@@ -28,7 +33,7 @@ def build_policy(self, request, response):
return build_policy(config=config, update=update, replace=replace, nonce=nonce)
- def build_policy_ro(self, request, response):
+ def build_policy_ro(self, request: HttpRequest, response: HttpResponseBase) -> str:
config = getattr(response, "_csp_config_ro", None)
update = getattr(response, "_csp_update_ro", None)
replace = getattr(response, "_csp_replace_ro", {})
diff --git a/csp/decorators.py b/csp/decorators.py
index 3d71b6c..bcac3b6 100644
--- a/csp/decorators.py
+++ b/csp/decorators.py
@@ -1,7 +1,17 @@
+from __future__ import annotations
+
from functools import wraps
+from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
+
+if TYPE_CHECKING:
+ from django.http import HttpRequest, HttpResponseBase
+
+ # A generic Django view function
+ _VIEW_T = Callable[[HttpRequest], HttpResponseBase]
+ _VIEW_DECORATOR_T = Callable[[_VIEW_T], _VIEW_T]
-def csp_exempt(REPORT_ONLY=None):
+def csp_exempt(REPORT_ONLY: Optional[bool] = None) -> _VIEW_DECORATOR_T:
if callable(REPORT_ONLY):
raise RuntimeError(
"Incompatible `csp_exempt` decorator usage. This decorator now requires arguments, "
@@ -10,14 +20,14 @@ def csp_exempt(REPORT_ONLY=None):
"information."
)
- def decorator(f):
+ def decorator(f: _VIEW_T) -> _VIEW_T:
@wraps(f)
- def _wrapped(*a, **kw):
+ def _wrapped(*a: Any, **kw: Any) -> HttpResponseBase:
resp = f(*a, **kw)
if REPORT_ONLY:
- resp._csp_exempt_ro = True
+ setattr(resp, "_csp_exempt_ro", True)
else:
- resp._csp_exempt = True
+ setattr(resp, "_csp_exempt", True)
return resp
return _wrapped
@@ -32,18 +42,18 @@ def _wrapped(*a, **kw):
)
-def csp_update(config=None, REPORT_ONLY=False, **kwargs):
+def csp_update(config: Optional[Dict[str, Any]] = 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"))
- def decorator(f):
+ def decorator(f: _VIEW_T) -> _VIEW_T:
@wraps(f)
- def _wrapped(*a, **kw):
+ def _wrapped(*a: Any, **kw: Any) -> HttpResponseBase:
resp = f(*a, **kw)
if REPORT_ONLY:
- resp._csp_update_ro = config
+ setattr(resp, "_csp_update_ro", config)
else:
- resp._csp_update = config
+ setattr(resp, "_csp_update", config)
return resp
return _wrapped
@@ -51,18 +61,18 @@ def _wrapped(*a, **kw):
return decorator
-def csp_replace(config=None, REPORT_ONLY=False, **kwargs):
+def csp_replace(config: Optional[Dict[str, Any]] = 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"))
- def decorator(f):
+ def decorator(f: _VIEW_T) -> _VIEW_T:
@wraps(f)
- def _wrapped(*a, **kw):
+ def _wrapped(*a: Any, **kw: Any) -> HttpResponseBase:
resp = f(*a, **kw)
if REPORT_ONLY:
- resp._csp_replace_ro = config
+ setattr(resp, "_csp_replace_ro", config)
else:
- resp._csp_replace = config
+ setattr(resp, "_csp_replace", config)
return resp
return _wrapped
@@ -70,20 +80,23 @@ def _wrapped(*a, **kw):
return decorator
-def csp(config=None, REPORT_ONLY=False, **kwargs):
+def csp(config: Optional[Dict[str, Any]] = None, REPORT_ONLY: bool = False, **kwargs: Any) -> _VIEW_DECORATOR_T:
if config is None and kwargs:
raise RuntimeError(DECORATOR_DEPRECATION_ERROR.format(fname="csp"))
- config = {k: [v] if isinstance(v, str) else v for k, v in config.items()}
+ if config is None:
+ processed_config: Dict[str, List[Any]] = {}
+ else:
+ processed_config = {k: [v] if isinstance(v, str) else v for k, v in config.items()}
- def decorator(f):
+ def decorator(f: _VIEW_T) -> _VIEW_T:
@wraps(f)
- def _wrapped(*a, **kw):
+ def _wrapped(*a: Any, **kw: Any) -> HttpResponseBase:
resp = f(*a, **kw)
if REPORT_ONLY:
- resp._csp_config_ro = config
+ setattr(resp, "_csp_config_ro", processed_config)
else:
- resp._csp_config = config
+ setattr(resp, "_csp_config", processed_config)
return resp
return _wrapped
diff --git a/csp/extensions/__init__.py b/csp/extensions/__init__.py
index 2e152b8..45c4de4 100644
--- a/csp/extensions/__init__.py
+++ b/csp/extensions/__init__.py
@@ -1,14 +1,20 @@
+from __future__ import annotations
+from typing import Callable, TYPE_CHECKING, Any
+
from jinja2 import nodes
from jinja2.ext import Extension
from csp.utils import SCRIPT_ATTRS, build_script_tag
+if TYPE_CHECKING:
+ from jinja2.parser import Parser
+
class NoncedScript(Extension):
# a set of names that trigger the extension.
tags = {"script"}
- def parse(self, parser):
+ def parse(self, parser: Parser) -> nodes.Node:
# the first token is the token that started the tag. In our case
# we only listen to ``'script'`` so this will be a name token with
# `script` as value. We get the line number so that we can give
@@ -26,13 +32,13 @@ def parse(self, parser):
# now we parse the body of the script block up to `endscript` and
# drop the needle (which would always be `endscript` in that case)
- body = parser.parse_statements(["name:endscript"], drop_needle=True)
+ body = parser.parse_statements(("name:endscript",), drop_needle=True)
# now return a `CallBlock` node that calls our _render_script
# helper method on this extension.
return nodes.CallBlock(self.call_method("_render_script", kwargs=kwargs), [], [], body).set_lineno(lineno)
- def _render_script(self, caller, **kwargs):
+ def _render_script(self, caller: Callable[[], str], **kwargs: Any) -> str:
ctx = kwargs.pop("ctx")
request = ctx.get("request")
kwargs["nonce"] = request.csp_nonce
diff --git a/csp/middleware.py b/csp/middleware.py
index 11bf799..03cc745 100644
--- a/csp/middleware.py
+++ b/csp/middleware.py
@@ -1,7 +1,9 @@
+from __future__ import annotations
import base64
import http.client as http_client
import os
from functools import partial
+from typing import TYPE_CHECKING
from django.conf import settings
from django.utils.deprecation import MiddlewareMixin
@@ -10,6 +12,9 @@
from csp.constants import HEADER, HEADER_REPORT_ONLY
from csp.utils import build_policy
+if TYPE_CHECKING:
+ from django.http import HttpRequest, HttpResponseBase
+
class CSPMiddleware(MiddlewareMixin):
"""
@@ -21,17 +26,20 @@ class CSPMiddleware(MiddlewareMixin):
"""
- def _make_nonce(self, request):
+ def _make_nonce(self, request: HttpRequest) -> str:
# Ensure that any subsequent calls to request.csp_nonce return the same value
- if not getattr(request, "_csp_nonce", None):
- request._csp_nonce = base64.b64encode(os.urandom(16)).decode("ascii")
- return request._csp_nonce
+ stored_nonce = getattr(request, "_csp_nonce", None)
+ if isinstance(stored_nonce, str):
+ return stored_nonce
+ nonce = base64.b64encode(os.urandom(16)).decode("ascii")
+ setattr(request, "_csp_nonce", nonce)
+ return nonce
- def process_request(self, request):
+ def process_request(self, request: HttpRequest) -> None:
nonce = partial(self._make_nonce, request)
- request.csp_nonce = SimpleLazyObject(nonce)
+ setattr(request, "csp_nonce", SimpleLazyObject(nonce))
- def process_response(self, request, response):
+ def process_response(self, request: HttpRequest, response: HttpResponseBase) -> HttpResponseBase:
# Check for debug view
exempted_debug_codes = (
http_client.INTERNAL_SERVER_ERROR,
@@ -45,8 +53,9 @@ def process_response(self, request, response):
# Only set header if not already set and not an excluded prefix and not exempted.
is_not_exempt = getattr(response, "_csp_exempt", False) is False
no_header = HEADER not in response
- prefixes = getattr(settings, "CONTENT_SECURITY_POLICY", {}).get("EXCLUDE_URL_PREFIXES", ())
- is_not_excluded = not request.path_info.startswith(prefixes)
+ policy = getattr(settings, "CONTENT_SECURITY_POLICY", None) or {}
+ prefixes = policy.get("EXCLUDE_URL_PREFIXES", None) or ()
+ is_not_excluded = not request.path_info.startswith(tuple(prefixes))
if all((no_header, is_not_exempt, is_not_excluded)):
response[HEADER] = csp
@@ -55,21 +64,22 @@ def process_response(self, request, response):
# Only set header if not already set and not an excluded prefix and not exempted.
is_not_exempt = getattr(response, "_csp_exempt_ro", False) is False
no_header = HEADER_REPORT_ONLY not in response
- prefixes = getattr(settings, "CONTENT_SECURITY_POLICY_REPORT_ONLY", {}).get("EXCLUDE_URL_PREFIXES", ())
- is_not_excluded = not request.path_info.startswith(prefixes)
+ policy = getattr(settings, "CONTENT_SECURITY_POLICY_REPORT_ONLY", None) or {}
+ prefixes = policy.get("EXCLUDE_URL_PREFIXES", None) or ()
+ is_not_excluded = not request.path_info.startswith(tuple(prefixes))
if all((no_header, is_not_exempt, is_not_excluded)):
response[HEADER_REPORT_ONLY] = csp_ro
return response
- def build_policy(self, request, response):
+ def build_policy(self, request: HttpRequest, response: HttpResponseBase) -> str:
config = getattr(response, "_csp_config", None)
update = getattr(response, "_csp_update", None)
replace = getattr(response, "_csp_replace", None)
nonce = getattr(request, "_csp_nonce", None)
return build_policy(config=config, update=update, replace=replace, nonce=nonce)
- def build_policy_ro(self, request, response):
+ def build_policy_ro(self, request: HttpRequest, response: HttpResponseBase) -> str:
config = getattr(response, "_csp_config_ro", None)
update = getattr(response, "_csp_update_ro", None)
replace = getattr(response, "_csp_replace_ro", None)
diff --git a/csp/py.typed b/csp/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/csp/templatetags/csp.py b/csp/templatetags/csp.py
index d31a921..a28bfc1 100644
--- a/csp/templatetags/csp.py
+++ b/csp/templatetags/csp.py
@@ -1,18 +1,24 @@
+from __future__ import annotations
+from typing import TYPE_CHECKING, Optional
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.context import Context
+
register = template.Library()
-def _unquote(s):
+def _unquote(s: str) -> str:
"""Helper func that strips single and double quotes from inside strings"""
return s.replace('"', "").replace("'", "")
@register.tag(name="script")
-def script(parser, token):
+def script(parser: Parser, token: Token) -> "NonceScriptNode":
# Parse out any keyword args
token_args = token.split_contents()
kwargs = token_kwargs(token_args[1:], parser)
@@ -24,19 +30,21 @@ def script(parser, token):
class NonceScriptNode(template.Node):
- def __init__(self, nodelist, **kwargs):
+ def __init__(self, nodelist: NodeList, **kwargs: FilterExpression) -> None:
self.nodelist = nodelist
self.script_attrs = {}
for k, v in kwargs.items():
self.script_attrs[k] = self._get_token_value(v)
- def _get_token_value(self, t):
- return _unquote(t.token) if getattr(t, "token", None) else None
+ def _get_token_value(self, t: FilterExpression) -> Optional[str]:
+ if hasattr(t, "token") and t.token:
+ return _unquote(t.token)
+ return None
- def render(self, context):
+ def render(self, context: Context) -> str:
output = self.nodelist.render(context).strip()
request = context.get("request")
- nonce = request.csp_nonce if hasattr(request, "csp_nonce") else ""
+ nonce = getattr(request, "csp_nonce", "")
self.script_attrs.update({"nonce": nonce, "content": output})
return build_script_tag(**self.script_attrs)
diff --git a/csp/tests/environment.py b/csp/tests/environment.py
index 9a218c5..a5d188d 100644
--- a/csp/tests/environment.py
+++ b/csp/tests/environment.py
@@ -1,6 +1,7 @@
from jinja2 import Environment
+from typing import Any
-def environment(**options):
+def environment(**options: Any) -> Environment:
env = Environment(**options)
return env
diff --git a/csp/tests/test_checks.py b/csp/tests/test_checks.py
index 9c5e8df..9fc52cd 100644
--- a/csp/tests/test_checks.py
+++ b/csp/tests/test_checks.py
@@ -10,7 +10,7 @@
CSP_REPORT_ONLY=False,
CSP_DEFAULT_SRC=["'self'", "example.com"],
)
-def test_migrate_settings():
+def test_migrate_settings() -> None:
config, report_only = migrate_settings()
assert config == {
"REPORT_PERCENTAGE": 25,
@@ -26,7 +26,7 @@ def test_migrate_settings():
CSP_SCRIPT_SRC=["'self'", "example.com", "'unsafe-inline'"],
CSP_INCLUDE_NONCE_IN=["script-src"],
)
-def test_migrate_settings_report_only():
+def test_migrate_settings_report_only() -> None:
config, report_only = migrate_settings()
assert config == {
"DIRECTIVES": {
@@ -40,7 +40,7 @@ def test_migrate_settings_report_only():
@override_settings(
CSP_DEFAULT_SRC=["'self'", "example.com"],
)
-def test_check_django_csp_lt_4_0():
+def test_check_django_csp_lt_4_0() -> None:
errors = check_django_csp_lt_4_0(None)
assert len(errors) == 1
error = errors[0]
@@ -48,5 +48,5 @@ def test_check_django_csp_lt_4_0():
assert "update your settings to use the new format" in error.msg
-def test_check_django_csp_lt_4_0_no_config():
+def test_check_django_csp_lt_4_0_no_config() -> None:
assert check_django_csp_lt_4_0(None) == []
diff --git a/csp/tests/test_constants.py b/csp/tests/test_constants.py
index 3c3944d..0a563ac 100644
--- a/csp/tests/test_constants.py
+++ b/csp/tests/test_constants.py
@@ -1,7 +1,7 @@
from csp import constants
-def test_nonce():
+def test_nonce() -> None:
assert constants.Nonce() == constants.Nonce()
assert constants.NONCE == constants.Nonce()
assert repr(constants.Nonce()) == "csp.constants.NONCE"
diff --git a/csp/tests/test_context_processors.py b/csp/tests/test_context_processors.py
index 666f53f..164e35a 100644
--- a/csp/tests/test_context_processors.py
+++ b/csp/tests/test_context_processors.py
@@ -9,7 +9,7 @@
mw = CSPMiddleware(response())
-def test_nonce_context_processor():
+def test_nonce_context_processor() -> None:
request = rf.get("/")
mw.process_request(request)
context = nonce(request)
@@ -17,10 +17,10 @@ def test_nonce_context_processor():
response = HttpResponse()
mw.process_response(request, response)
- assert context["CSP_NONCE"] == request.csp_nonce
+ assert context["CSP_NONCE"] == getattr(request, "csp_nonce")
-def test_nonce_context_processor_with_middleware_disabled():
+def test_nonce_context_processor_with_middleware_disabled() -> None:
request = rf.get("/")
context = nonce(request)
diff --git a/csp/tests/test_contrib.py b/csp/tests/test_contrib.py
index 5c16fec..c62e767 100644
--- a/csp/tests/test_contrib.py
+++ b/csp/tests/test_contrib.py
@@ -11,7 +11,7 @@
@override_settings(CONTENT_SECURITY_POLICY={"REPORT_PERCENTAGE": 10, "DIRECTIVES": {"report-uri": "x"}})
-def test_report_percentage():
+def test_report_percentage() -> None:
times_seen = 0
for _ in range(5000):
request = rf.get("/")
@@ -24,7 +24,7 @@ def test_report_percentage():
@override_settings(CONTENT_SECURITY_POLICY_REPORT_ONLY={"REPORT_PERCENTAGE": 10, "DIRECTIVES": {"report-uri": "x"}})
-def test_report_percentage_report_only():
+def test_report_percentage_report_only() -> None:
times_seen = 0
for _ in range(5000):
request = rf.get("/")
@@ -37,7 +37,7 @@ def test_report_percentage_report_only():
@override_settings(CONTENT_SECURITY_POLICY=None)
-def test_no_csp():
+def test_no_csp() -> None:
request = rf.get("/")
response = HttpResponse()
mw.process_response(request, response)
@@ -45,7 +45,7 @@ def test_no_csp():
@override_settings(CONTENT_SECURITY_POLICY_REPORT_ONLY=None)
-def test_no_csp_ro():
+def test_no_csp_ro() -> None:
request = rf.get("/")
response = HttpResponse()
mw.process_response(request, response)
diff --git a/csp/tests/test_decorators.py b/csp/tests/test_decorators.py
index d75819d..1a11dfd 100644
--- a/csp/tests/test_decorators.py
+++ b/csp/tests/test_decorators.py
@@ -1,3 +1,6 @@
+from __future__ import annotations
+from typing import TYPE_CHECKING
+
import pytest
from django.http import HttpResponse
from django.test import RequestFactory
@@ -8,34 +11,37 @@
from csp.middleware import CSPMiddleware
from csp.tests.utils import response
+if TYPE_CHECKING:
+ from django.http import HttpRequest, HttpResponseBase
+
mw = CSPMiddleware(response())
-def test_csp_exempt():
+def test_csp_exempt() -> None:
@csp_exempt()
- def view(request):
+ def view(request: HttpRequest) -> HttpResponseBase:
return HttpResponse()
response = view(RequestFactory().get("/"))
- assert response._csp_exempt is True
+ assert getattr(response, "_csp_exempt") is True
assert not hasattr(response, "_csp_exempt_ro")
-def test_csp_exempt_ro():
+def test_csp_exempt_ro() -> None:
@csp_exempt(REPORT_ONLY=True)
- def view(request):
+ def view(request: HttpRequest) -> HttpResponseBase:
return HttpResponse()
response = view(RequestFactory().get("/"))
assert not hasattr(response, "_csp_exempt")
- assert response._csp_exempt_ro is True
+ assert getattr(response, "_csp_exempt_ro") is True
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ["foo.com"]}})
-def test_csp_update():
+def test_csp_update() -> None:
request = RequestFactory().get("/")
- def view_without_decorator(request):
+ def view_without_decorator(request: HttpRequest) -> HttpResponseBase:
return HttpResponse()
response = view_without_decorator(request)
@@ -45,17 +51,17 @@ def view_without_decorator(request):
assert policy_list == ["default-src 'self'", "img-src foo.com"]
@csp_update({"img-src": ["bar.com", NONCE]})
- def view_with_decorator(request):
+ def view_with_decorator(request: HttpRequest) -> HttpResponseBase:
return HttpResponse()
response = view_with_decorator(request)
- assert response._csp_update == {"img-src": ["bar.com", NONCE]}
+ assert getattr(response, "_csp_update") == {"img-src": ["bar.com", NONCE]}
mw.process_request(request)
- assert request.csp_nonce # Here to trigger the nonce creation.
+ assert getattr(request, "csp_nonce") # Here to trigger the nonce creation.
mw.process_response(request, response)
assert HEADER_REPORT_ONLY not in response.headers
policy_list = sorted(response[HEADER].split("; "))
- assert policy_list == ["default-src 'self'", f"img-src foo.com bar.com 'nonce-{request.csp_nonce}'"]
+ assert policy_list == ["default-src 'self'", f"img-src foo.com bar.com 'nonce-{getattr(request, 'csp_nonce')}'"]
response = view_without_decorator(request)
mw.process_response(request, response)
@@ -65,10 +71,10 @@ def view_with_decorator(request):
@override_settings(CONTENT_SECURITY_POLICY=None, CONTENT_SECURITY_POLICY_REPORT_ONLY={"DIRECTIVES": {"img-src": ["foo.com"]}})
-def test_csp_update_ro():
+def test_csp_update_ro() -> None:
request = RequestFactory().get("/")
- def view_without_decorator(request):
+ def view_without_decorator(request: HttpRequest) -> HttpResponseBase:
return HttpResponse()
response = view_without_decorator(request)
@@ -78,17 +84,17 @@ def view_without_decorator(request):
assert policy_list == ["default-src 'self'", "img-src foo.com"]
@csp_update({"img-src": ["bar.com", NONCE]}, REPORT_ONLY=True)
- def view_with_decorator(request):
+ def view_with_decorator(request: HttpRequest) -> HttpResponseBase:
return HttpResponse()
response = view_with_decorator(request)
- assert response._csp_update_ro == {"img-src": ["bar.com", NONCE]}
+ assert getattr(response, "_csp_update_ro") == {"img-src": ["bar.com", NONCE]}
mw.process_request(request)
- assert request.csp_nonce # Here to trigger the nonce creation.
+ assert getattr(request, "csp_nonce") # Here to trigger the nonce creation.
mw.process_response(request, response)
assert HEADER not in response.headers
policy_list = sorted(response[HEADER_REPORT_ONLY].split("; "))
- assert policy_list == ["default-src 'self'", f"img-src foo.com bar.com 'nonce-{request.csp_nonce}'"]
+ assert policy_list == ["default-src 'self'", f"img-src foo.com bar.com 'nonce-{getattr(request, 'csp_nonce')}'"]
response = view_without_decorator(request)
mw.process_response(request, response)
@@ -98,10 +104,10 @@ def view_with_decorator(request):
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ["foo.com"]}})
-def test_csp_replace():
+def test_csp_replace() -> None:
request = RequestFactory().get("/")
- def view_without_decorator(request):
+ def view_without_decorator(request: HttpRequest) -> HttpResponseBase:
return HttpResponse()
response = view_without_decorator(request)
@@ -111,11 +117,11 @@ def view_without_decorator(request):
assert policy_list == ["default-src 'self'", "img-src foo.com"]
@csp_replace({"img-src": ["bar.com"]})
- def view_with_decorator(request):
+ def view_with_decorator(request: HttpRequest) -> HttpResponseBase:
return HttpResponse()
response = view_with_decorator(request)
- assert response._csp_replace == {"img-src": ["bar.com"]}
+ assert getattr(response, "_csp_replace") == {"img-src": ["bar.com"]}
mw.process_response(request, response)
assert HEADER_REPORT_ONLY not in response.headers
policy_list = sorted(response[HEADER].split("; "))
@@ -128,7 +134,7 @@ def view_with_decorator(request):
assert policy_list == ["default-src 'self'", "img-src foo.com"]
@csp_replace({"img-src": None})
- def view_removing_directive(request):
+ def view_removing_directive(request: HttpRequest) -> HttpResponseBase:
return HttpResponse()
response = view_removing_directive(request)
@@ -139,10 +145,10 @@ def view_removing_directive(request):
@override_settings(CONTENT_SECURITY_POLICY=None, CONTENT_SECURITY_POLICY_REPORT_ONLY={"DIRECTIVES": {"img-src": ["foo.com"]}})
-def test_csp_replace_ro():
+def test_csp_replace_ro() -> None:
request = RequestFactory().get("/")
- def view_without_decorator(request):
+ def view_without_decorator(request: HttpRequest) -> HttpResponseBase:
return HttpResponse()
response = view_without_decorator(request)
@@ -152,11 +158,11 @@ def view_without_decorator(request):
assert policy_list == ["default-src 'self'", "img-src foo.com"]
@csp_replace({"img-src": ["bar.com"]}, REPORT_ONLY=True)
- def view_with_decorator(request):
+ def view_with_decorator(request: HttpRequest) -> HttpResponseBase:
return HttpResponse()
response = view_with_decorator(request)
- assert response._csp_replace_ro == {"img-src": ["bar.com"]}
+ assert getattr(response, "_csp_replace_ro") == {"img-src": ["bar.com"]}
mw.process_response(request, response)
assert HEADER not in response.headers
policy_list = sorted(response[HEADER_REPORT_ONLY].split("; "))
@@ -169,7 +175,7 @@ def view_with_decorator(request):
assert policy_list == ["default-src 'self'", "img-src foo.com"]
@csp_replace({"img-src": None}, REPORT_ONLY=True)
- def view_removing_directive(request):
+ def view_removing_directive(request: HttpRequest) -> HttpResponseBase:
return HttpResponse()
response = view_removing_directive(request)
@@ -179,10 +185,10 @@ def view_removing_directive(request):
assert policy_list == ["default-src 'self'"]
-def test_csp():
+def test_csp() -> None:
request = RequestFactory().get("/")
- def view_without_decorator(request):
+ def view_without_decorator(request: HttpRequest) -> HttpResponseBase:
return HttpResponse()
response = view_without_decorator(request)
@@ -192,11 +198,11 @@ def view_without_decorator(request):
assert policy_list == ["default-src 'self'"]
@csp({"img-src": ["foo.com"], "font-src": ["bar.com"]})
- def view_with_decorator(request):
+ def view_with_decorator(request: HttpRequest) -> HttpResponseBase:
return HttpResponse()
response = view_with_decorator(request)
- assert response._csp_config == {"img-src": ["foo.com"], "font-src": ["bar.com"]}
+ assert getattr(response, "_csp_config") == {"img-src": ["foo.com"], "font-src": ["bar.com"]}
mw.process_response(request, response)
assert HEADER_REPORT_ONLY not in response.headers
policy_list = sorted(response[HEADER].split("; "))
@@ -209,10 +215,10 @@ def view_with_decorator(request):
assert policy_list == ["default-src 'self'"]
-def test_csp_ro():
+def test_csp_ro() -> None:
request = RequestFactory().get("/")
- def view_without_decorator(request):
+ def view_without_decorator(request: HttpRequest) -> HttpResponseBase:
return HttpResponse()
response = view_without_decorator(request)
@@ -223,11 +229,11 @@ def view_without_decorator(request):
@csp({"img-src": ["foo.com"], "font-src": ["bar.com"]}, REPORT_ONLY=True)
@csp({}) # CSP with no directives effectively removes the header.
- def view_with_decorator(request):
+ def view_with_decorator(request: HttpRequest) -> HttpResponseBase:
return HttpResponse()
response = view_with_decorator(request)
- assert response._csp_config_ro == {"img-src": ["foo.com"], "font-src": ["bar.com"]}
+ assert getattr(response, "_csp_config_ro") == {"img-src": ["foo.com"], "font-src": ["bar.com"]}
mw.process_response(request, response)
assert HEADER not in response.headers
policy_list = sorted(response[HEADER_REPORT_ONLY].split("; "))
@@ -240,16 +246,16 @@ def view_with_decorator(request):
assert policy_list == ["default-src 'self'"]
-def test_csp_string_values():
+def test_csp_string_values() -> None:
# Test backwards compatibility where values were strings
request = RequestFactory().get("/")
@csp({"img-src": "foo.com", "font-src": "bar.com"})
- def view_with_decorator(request):
+ def view_with_decorator(request: HttpRequest) -> HttpResponseBase:
return HttpResponse()
response = view_with_decorator(request)
- assert response._csp_config == {"img-src": ["foo.com"], "font-src": ["bar.com"]}
+ assert getattr(response, "_csp_config") == {"img-src": ["foo.com"], "font-src": ["bar.com"]}
mw.process_response(request, response)
policy_list = sorted(response[HEADER].split("; "))
assert policy_list == ["font-src bar.com", "img-src foo.com"]
@@ -258,41 +264,41 @@ def view_with_decorator(request):
# Deprecation tests
-def test_csp_exempt_error():
+def test_csp_exempt_error() -> None:
with pytest.raises(RuntimeError) as excinfo:
-
- @csp_exempt
- def view(request):
+ # Ignore type error since we're checking for the exception raised for 3.x syntax
+ @csp_exempt # type: ignore
+ def view(request: HttpRequest) -> HttpResponseBase:
return HttpResponse()
assert "Incompatible `csp_exempt` decorator usage" in str(excinfo.value)
-def test_csp_update_error():
+def test_csp_update_error() -> None:
with pytest.raises(RuntimeError) as excinfo:
@csp_update(IMG_SRC="bar.com")
- def view(request):
+ def view(request: HttpRequest) -> HttpResponseBase:
return HttpResponse()
assert "Incompatible `csp_update` decorator arguments" in str(excinfo.value)
-def test_csp_replace_error():
+def test_csp_replace_error() -> None:
with pytest.raises(RuntimeError) as excinfo:
@csp_replace(IMG_SRC="bar.com")
- def view(request):
+ def view(request: HttpRequest) -> HttpResponseBase:
return HttpResponse()
assert "Incompatible `csp_replace` decorator arguments" in str(excinfo.value)
-def test_csp_error():
+def test_csp_error() -> None:
with pytest.raises(RuntimeError) as excinfo:
@csp(IMG_SRC=["bar.com"])
- def view(request):
+ def view(request: HttpRequest) -> HttpResponseBase:
return HttpResponse()
assert "Incompatible `csp` decorator arguments" in str(excinfo.value)
diff --git a/csp/tests/test_jinja_extension.py b/csp/tests/test_jinja_extension.py
index 227feb3..b9497c6 100644
--- a/csp/tests/test_jinja_extension.py
+++ b/csp/tests/test_jinja_extension.py
@@ -2,7 +2,7 @@
class TestJinjaExtension(ScriptExtensionTestBase):
- def test_script_tag_injects_nonce(self):
+ def test_script_tag_injects_nonce(self) -> None:
tpl = """
{% script %}
var hello='world';
@@ -12,7 +12,7 @@ def test_script_tag_injects_nonce(self):
expected = """"""
self.assert_template_eq(*self.process_templates(tpl, expected))
- def test_script_with_src_ignores_body(self):
+ def test_script_with_src_ignores_body(self) -> None:
tpl = """
{% script src="foo" %}
var hello='world';
@@ -23,7 +23,7 @@ def test_script_with_src_ignores_body(self):
self.assert_template_eq(*self.process_templates(tpl, expected))
- def test_script_tag_sets_attrs_correctly(self):
+ def test_script_tag_sets_attrs_correctly(self) -> None:
tpl = """
{% script id='jeff' defer=True %}
var hello='world';
@@ -36,7 +36,7 @@ def test_script_tag_sets_attrs_correctly(self):
self.assert_template_eq(*self.process_templates(tpl, expected))
- def test_async_attribute_with_falsey(self):
+ def test_async_attribute_with_falsey(self) -> None:
tpl = """
{% script id="jeff" async=False %}
var hello='world';
@@ -46,7 +46,7 @@ def test_async_attribute_with_falsey(self):
self.assert_template_eq(*self.process_templates(tpl, expected))
- def test_async_attribute_with_truthy(self):
+ def test_async_attribute_with_truthy(self) -> None:
tpl = """
{% script id="jeff" async=True %}
var hello='world';
@@ -56,7 +56,7 @@ def test_async_attribute_with_truthy(self):
self.assert_template_eq(*self.process_templates(tpl, expected))
- def test_nested_script_tags_are_removed(self):
+ def test_nested_script_tags_are_removed(self) -> None:
"""Let users wrap their code in script tags for the sake of their
development environment"""
tpl = """
@@ -70,7 +70,7 @@ def test_nested_script_tags_are_removed(self):
self.assert_template_eq(*self.process_templates(tpl, expected))
- def test_regex_captures_script_content_including_brackets(self):
+ def test_regex_captures_script_content_including_brackets(self) -> None:
"""
Ensure that script content get captured properly.
Especially when using angle brackets."""
diff --git a/csp/tests/test_middleware.py b/csp/tests/test_middleware.py
index f8fad35..e2e7e54 100644
--- a/csp/tests/test_middleware.py
+++ b/csp/tests/test_middleware.py
@@ -14,7 +14,7 @@
rf = RequestFactory()
-def test_add_header():
+def test_add_header() -> None:
request = rf.get("/")
response = HttpResponse()
mw.process_response(request, response)
@@ -25,7 +25,7 @@ def test_add_header():
CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": ["example.com"]}},
CONTENT_SECURITY_POLICY_REPORT_ONLY={"DIRECTIVES": {"default-src": [SELF]}},
)
-def test_both_headers():
+def test_both_headers() -> None:
request = rf.get("/")
response = HttpResponse()
mw.process_response(request, response)
@@ -33,16 +33,16 @@ def test_both_headers():
assert HEADER_REPORT_ONLY in response
-def test_exempt():
+def test_exempt() -> None:
request = rf.get("/")
response = HttpResponse()
- response._csp_exempt = True
+ setattr(response, "_csp_exempt", True)
mw.process_response(request, response)
assert HEADER not in response
@override_settings(CONTENT_SECURITY_POLICY={"EXCLUDE_URL_PREFIXES": ["/inlines-r-us"]})
-def text_exclude():
+def test_exclude() -> None:
request = rf.get("/inlines-r-us/foo")
response = HttpResponse()
mw.process_response(request, response)
@@ -53,7 +53,7 @@ def text_exclude():
CONTENT_SECURITY_POLICY=None,
CONTENT_SECURITY_POLICY_REPORT_ONLY={"DIRECTIVES": {"default-src": [SELF]}},
)
-def test_report_only():
+def test_report_only() -> None:
request = rf.get("/")
response = HttpResponse()
mw.process_response(request, response)
@@ -61,7 +61,7 @@ def test_report_only():
assert HEADER + "-Report-Only" in response
-def test_dont_replace():
+def test_dont_replace() -> None:
request = rf.get("/")
response = HttpResponse()
response[HEADER] = "default-src example.com"
@@ -69,34 +69,34 @@ def test_dont_replace():
assert response[HEADER] == "default-src example.com"
-def test_use_config():
+def test_use_config() -> None:
request = rf.get("/")
response = HttpResponse()
- response._csp_config = {"default-src": ["example.com"]}
+ setattr(response, "_csp_config", {"default-src": ["example.com"]})
mw.process_response(request, response)
assert response[HEADER] == "default-src example.com"
-def test_use_update():
+def test_use_update() -> None:
request = rf.get("/")
response = HttpResponse()
- response._csp_update = {"default-src": ["example.com"]}
+ setattr(response, "_csp_update", {"default-src": ["example.com"]})
mw.process_response(request, response)
assert response[HEADER] == "default-src 'self' example.com"
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ["foo.com"]}})
-def test_use_replace():
+def test_use_replace() -> None:
request = rf.get("/")
response = HttpResponse()
- response._csp_replace = {"img-src": ["bar.com"]}
+ setattr(response, "_csp_replace", {"img-src": ["bar.com"]})
mw.process_response(request, response)
policy_list = sorted(response[HEADER].split("; "))
assert policy_list == ["default-src 'self'", "img-src bar.com"]
@override_settings(DEBUG=True)
-def test_debug_errors_exempt():
+def test_debug_errors_exempt() -> None:
request = rf.get("/")
response = HttpResponseServerError()
mw.process_response(request, response)
@@ -104,23 +104,23 @@ def test_debug_errors_exempt():
@override_settings(DEBUG=True)
-def test_debug_notfound_exempt():
+def test_debug_notfound_exempt() -> None:
request = rf.get("/")
response = HttpResponseNotFound()
mw.process_response(request, response)
assert HEADER not in response
-def test_nonce_created_when_accessed():
+def test_nonce_created_when_accessed() -> None:
request = rf.get("/")
mw.process_request(request)
- nonce = str(request.csp_nonce)
+ nonce = str(getattr(request, "csp_nonce"))
response = HttpResponse()
mw.process_response(request, response)
assert nonce in response[HEADER]
-def test_no_nonce_when_not_accessed():
+def test_no_nonce_when_not_accessed() -> None:
request = rf.get("/")
mw.process_request(request)
response = HttpResponse()
@@ -128,14 +128,14 @@ def test_no_nonce_when_not_accessed():
assert "nonce-" not in response[HEADER]
-def test_nonce_regenerated_on_new_request():
+def test_nonce_regenerated_on_new_request() -> None:
request1 = rf.get("/")
request2 = rf.get("/")
mw.process_request(request1)
mw.process_request(request2)
- nonce1 = str(request1.csp_nonce)
- nonce2 = str(request2.csp_nonce)
- assert request1.csp_nonce != request2.csp_nonce
+ nonce1 = str(getattr(request1, "csp_nonce"))
+ nonce2 = str(getattr(request2, "csp_nonce"))
+ assert nonce1 != nonce2
response1 = HttpResponse()
response2 = HttpResponse()
diff --git a/csp/tests/test_templatetags.py b/csp/tests/test_templatetags.py
index 45e96d5..308175d 100644
--- a/csp/tests/test_templatetags.py
+++ b/csp/tests/test_templatetags.py
@@ -2,7 +2,7 @@
class TestDjangoTemplateTag(ScriptTagTestBase):
- def test_script_tag_injects_nonce(self):
+ def test_script_tag_injects_nonce(self) -> None:
tpl = """
{% load csp %}
{% script %}var hello='world';{% endscript %}"""
@@ -11,7 +11,7 @@ def test_script_tag_injects_nonce(self):
self.assert_template_eq(*self.process_templates(tpl, expected))
- def test_script_with_src_ignores_body(self):
+ def test_script_with_src_ignores_body(self) -> None:
tpl = """
{% load csp %}
{% script src="foo" %}
@@ -22,7 +22,7 @@ def test_script_with_src_ignores_body(self):
self.assert_template_eq(*self.process_templates(tpl, expected))
- def test_script_tag_sets_attrs_correctly(self):
+ def test_script_tag_sets_attrs_correctly(self) -> None:
tpl = """
{% load csp %}
{% script type="application/javascript" id="jeff" defer=True%}
@@ -33,7 +33,7 @@ def test_script_tag_sets_attrs_correctly(self):
self.assert_template_eq(*self.process_templates(tpl, expected))
- def test_async_attribute_with_falsey(self):
+ def test_async_attribute_with_falsey(self) -> None:
tpl = """
{% load csp %}
{% script src="foo.com/bar.js" async=False %}
@@ -43,7 +43,7 @@ def test_async_attribute_with_falsey(self):
self.assert_template_eq(*self.process_templates(tpl, expected))
- def test_async_attribute_with_truthy(self):
+ def test_async_attribute_with_truthy(self) -> None:
tpl = """
{% load csp %}
{% script src="foo.com/bar.js" async=True %}
@@ -54,7 +54,7 @@ def test_async_attribute_with_truthy(self):
self.assert_template_eq(*self.process_templates(tpl, expected))
- def test_nested_script_tags_are_removed(self):
+ def test_nested_script_tags_are_removed(self) -> None:
"""Lets end users wrap their code in script tags for the sake of their
development environment"""
tpl = """
@@ -69,7 +69,7 @@ def test_nested_script_tags_are_removed(self):
self.assert_template_eq(*self.process_templates(tpl, expected))
- def test_regex_captures_script_content_including_brackets(self):
+ def test_regex_captures_script_content_including_brackets(self) -> None:
"""
Ensure that script content get captured properly.
Especially when using angle brackets."""
diff --git a/csp/tests/test_utils.py b/csp/tests/test_utils.py
index e8077a4..d76f49a 100644
--- a/csp/tests/test_utils.py
+++ b/csp/tests/test_utils.py
@@ -5,196 +5,196 @@
from csp.utils import build_policy, default_config, DEFAULT_DIRECTIVES
-def policy_eq(a, b):
+def policy_eq(a: str, b: str) -> None:
parts_a = sorted(a.split("; "))
parts_b = sorted(b.split("; "))
assert parts_a == parts_b, f"{a!r} != {b!r}"
-def literal(s):
+def literal(s: str) -> str:
return s
lazy_literal = lazy(literal, str)
-def test_default_config_none():
+def test_default_config_none() -> None:
assert default_config(None) is None
-def test_default_config_empty():
+def test_default_config_empty() -> None:
# Test `default_config` with an empty dict returns defaults.
assert default_config({}) == DEFAULT_DIRECTIVES
-def test_default_config_drops_unknown():
+def test_default_config_drops_unknown() -> None:
# Test `default_config` drops unknown keys.
config = {"foo-src": ["example.com"]}
assert default_config(config) == DEFAULT_DIRECTIVES
-def test_default_config():
+def test_default_config() -> None:
# Test `default_config` keeps config along with defaults.
config = {"img-src": ["example.com"]}
assert default_config(config) == {**DEFAULT_DIRECTIVES, **config}
-def test_empty_policy():
+def test_empty_policy() -> None:
policy = build_policy()
policy_eq("default-src 'self'", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": None}})
-def test_default_src_none():
+def test_default_src_none() -> None:
policy = build_policy()
policy_eq("", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": ["example.com", "example2.com"]}})
-def test_default_src():
+def test_default_src() -> None:
policy = build_policy()
policy_eq("default-src example.com example2.com", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"script-src": ["example.com"]}})
-def test_script_src():
+def test_script_src() -> None:
policy = build_policy()
policy_eq("default-src 'self'; script-src example.com", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"script-src-attr": ["example.com"]}})
-def test_script_src_attr():
+def test_script_src_attr() -> None:
policy = build_policy()
policy_eq("default-src 'self'; script-src-attr example.com", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"script-src-elem": ["example.com"]}})
-def test_script_src_elem():
+def test_script_src_elem() -> None:
policy = build_policy()
policy_eq("default-src 'self'; script-src-elem example.com", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"object-src": ["example.com"]}})
-def test_object_src():
+def test_object_src() -> None:
policy = build_policy()
policy_eq("default-src 'self'; object-src example.com", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"prefetch-src": ["example.com"]}})
-def test_prefetch_src():
+def test_prefetch_src() -> None:
policy = build_policy()
policy_eq("default-src 'self'; prefetch-src example.com", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"style-src": ["example.com"]}})
-def test_style_src():
+def test_style_src() -> None:
policy = build_policy()
policy_eq("default-src 'self'; style-src example.com", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"style-src-attr": ["example.com"]}})
-def test_style_src_attr():
+def test_style_src_attr() -> None:
policy = build_policy()
policy_eq("default-src 'self'; style-src-attr example.com", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"style-src-elem": ["example.com"]}})
-def test_style_src_elem():
+def test_style_src_elem() -> None:
policy = build_policy()
policy_eq("default-src 'self'; style-src-elem example.com", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ["example.com"]}})
-def test_img_src():
+def test_img_src() -> None:
policy = build_policy()
policy_eq("default-src 'self'; img-src example.com", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"media-src": ["example.com"]}})
-def test_media_src():
+def test_media_src() -> None:
policy = build_policy()
policy_eq("default-src 'self'; media-src example.com", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"frame-src": ["example.com"]}})
-def test_frame_src():
+def test_frame_src() -> None:
policy = build_policy()
policy_eq("default-src 'self'; frame-src example.com", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"font-src": ["example.com"]}})
-def test_font_src():
+def test_font_src() -> None:
policy = build_policy()
policy_eq("default-src 'self'; font-src example.com", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"connect-src": ["example.com"]}})
-def test_connect_src():
+def test_connect_src() -> None:
policy = build_policy()
policy_eq("default-src 'self'; connect-src example.com", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"sandbox": ["allow-scripts"]}})
-def test_sandbox():
+def test_sandbox() -> None:
policy = build_policy()
policy_eq("default-src 'self'; sandbox allow-scripts", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"sandbox": []}})
-def test_sandbox_empty():
+def test_sandbox_empty() -> None:
policy = build_policy()
policy_eq("default-src 'self'; sandbox", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"report-uri": "/foo"}})
-def test_report_uri():
+def test_report_uri() -> None:
policy = build_policy()
policy_eq("default-src 'self'; report-uri /foo", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"report-uri": lazy_literal("/foo")}})
-def test_report_uri_lazy():
+def test_report_uri_lazy() -> None:
policy = build_policy()
policy_eq("default-src 'self'; report-uri /foo", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"report-to": "some_endpoint"}})
-def test_report_to():
+def test_report_to() -> None:
policy = build_policy()
policy_eq("default-src 'self'; report-to some_endpoint", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ["example.com"]}})
-def test_update_img():
+def test_update_img() -> None:
policy = build_policy(update={"img-src": "example2.com"})
policy_eq("default-src 'self'; img-src example.com example2.com", policy)
-def test_update_missing_setting():
+def test_update_missing_setting() -> None:
"""update should work even if the setting is not defined."""
policy = build_policy(update={"img-src": "example.com"})
policy_eq("default-src 'self'; img-src example.com", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ["example.com"]}})
-def test_replace_img():
+def test_replace_img() -> None:
policy = build_policy(replace={"img-src": "example2.com"})
policy_eq("default-src 'self'; img-src example2.com", policy)
-def test_replace_missing_setting():
+def test_replace_missing_setting() -> None:
"""replace should work even if the setting is not defined."""
policy = build_policy(replace={"img-src": "example.com"})
policy_eq("default-src 'self'; img-src example.com", policy)
-def test_config():
+def test_config() -> None:
policy = build_policy(config={"default-src": [NONE], "img-src": [SELF]})
policy_eq("default-src 'none'; img-src 'self'", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ("example.com",)}})
-def test_update_string():
+def test_update_string() -> None:
"""
GitHub issue #40 - given project settings as a tuple, and
an update/replace with a string, concatenate correctly.
@@ -204,7 +204,7 @@ def test_update_string():
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ("example.com",)}})
-def test_replace_string():
+def test_replace_string() -> None:
"""
Demonstrate that GitHub issue #40 doesn't affect replacements
"""
@@ -213,67 +213,67 @@ def test_replace_string():
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"form-action": ["example.com"]}})
-def test_form_action():
+def test_form_action() -> None:
policy = build_policy()
policy_eq("default-src 'self'; form-action example.com", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"base-uri": ["example.com"]}})
-def test_base_uri():
+def test_base_uri() -> None:
policy = build_policy()
policy_eq("default-src 'self'; base-uri example.com", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"child-src": ["example.com"]}})
-def test_child_src():
+def test_child_src() -> None:
policy = build_policy()
policy_eq("default-src 'self'; child-src example.com", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"frame-ancestors": ["example.com"]}})
-def test_frame_ancestors():
+def test_frame_ancestors() -> None:
policy = build_policy()
policy_eq("default-src 'self'; frame-ancestors example.com", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"navigate-to": ["example.com"]}})
-def test_navigate_to():
+def test_navigate_to() -> None:
policy = build_policy()
policy_eq("default-src 'self'; navigate-to example.com", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"manifest-src": ["example.com"]}})
-def test_manifest_src():
+def test_manifest_src() -> None:
policy = build_policy()
policy_eq("default-src 'self'; manifest-src example.com", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"worker-src": ["example.com"]}})
-def test_worker_src():
+def test_worker_src() -> None:
policy = build_policy()
policy_eq("default-src 'self'; worker-src example.com", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"plugin-types": ["application/pdf"]}})
-def test_plugin_types():
+def test_plugin_types() -> None:
policy = build_policy()
policy_eq("default-src 'self'; plugin-types application/pdf", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"require-sri-for": ["script"]}})
-def test_require_sri_for():
+def test_require_sri_for() -> None:
policy = build_policy()
policy_eq("default-src 'self'; require-sri-for script", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"require-trusted-types-for": ["'script'"]}})
-def test_require_trusted_types_for():
+def test_require_trusted_types_for() -> None:
policy = build_policy()
policy_eq("default-src 'self'; require-trusted-types-for 'script'", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"trusted-types": ["strictPolicy", "laxPolicy", "default", "'allow-duplicates'"]}})
-def test_trusted_types():
+def test_trusted_types() -> None:
policy = build_policy()
policy_eq(
"default-src 'self'; trusted-types strictPolicy laxPolicy default 'allow-duplicates'",
@@ -282,24 +282,24 @@ def test_trusted_types():
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"upgrade-insecure-requests": True}})
-def test_upgrade_insecure_requests():
+def test_upgrade_insecure_requests() -> None:
policy = build_policy()
policy_eq("default-src 'self'; upgrade-insecure-requests", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"block-all-mixed-content": True}})
-def test_block_all_mixed_content():
+def test_block_all_mixed_content() -> None:
policy = build_policy()
policy_eq("default-src 'self'; block-all-mixed-content", policy)
-def test_nonce():
+def test_nonce() -> None:
policy = build_policy(nonce="abc123")
policy_eq("default-src 'self' 'nonce-abc123'", policy)
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": [SELF], "script-src": [SELF, NONCE], "style-src": [SELF, NONCE]}})
-def test_nonce_in_value():
+def test_nonce_in_value() -> None:
policy = build_policy(nonce="abc123")
policy_eq(
"default-src 'self'; script-src 'self' 'nonce-abc123'; style-src 'self' 'nonce-abc123'",
@@ -308,12 +308,12 @@ def test_nonce_in_value():
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": [NONCE]}})
-def test_only_nonce_in_value():
+def test_only_nonce_in_value() -> None:
policy = build_policy(nonce="abc123")
policy_eq("default-src 'nonce-abc123'", policy)
-def test_boolean_directives():
+def test_boolean_directives() -> None:
for directive in ["upgrade-insecure-requests", "block-all-mixed-content"]:
with override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {directive: True}}):
policy = build_policy()
diff --git a/csp/tests/utils.py b/csp/tests/utils.py
index 2d96b1c..36512ca 100644
--- a/csp/tests/utils.py
+++ b/csp/tests/utils.py
@@ -1,12 +1,20 @@
+from __future__ import annotations
+from abc import ABC, abstractmethod
+from typing import Dict, Optional, TYPE_CHECKING, Callable, Any, Tuple
+
from django.http import HttpResponse
from django.template import Context, Template, engines
from django.test import RequestFactory
+from django.utils.functional import SimpleLazyObject
from csp.middleware import CSPMiddleware
+if TYPE_CHECKING:
+ from django.http import HttpRequest
+
-def response(*args, headers=None, **kwargs):
- def get_response(req):
+def response(*args: Any, headers: Optional[Dict[str, str]] = None, **kwargs: Any) -> Callable[[HttpRequest], HttpResponse]:
+ def get_response(req: HttpRequest) -> HttpResponse:
response = HttpResponse(*args, **kwargs)
if headers:
for k, v in headers.items():
@@ -21,33 +29,32 @@ def get_response(req):
rf = RequestFactory()
-class ScriptTestBase:
- def assert_template_eq(self, tpl1, tpl2):
+class ScriptTestBase(ABC):
+ def assert_template_eq(self, tpl1: str, tpl2: str) -> None:
aaa = tpl1.replace("\n", "").replace(" ", "")
bbb = tpl2.replace("\n", "").replace(" ", "")
assert aaa == bbb, f"{aaa} != {bbb}"
- def process_templates(self, tpl, expected):
+ def process_templates(self, tpl: str, expected: str) -> Tuple[str, str]:
request = rf.get("/")
mw.process_request(request)
- ctx = self.make_context(request)
- return (
- self.make_template(tpl).render(ctx).strip(),
- expected.format(request.csp_nonce),
- )
+ nonce = getattr(request, "csp_nonce")
+ assert isinstance(nonce, SimpleLazyObject)
+ return (self.render(tpl, request).strip(), expected.format(nonce))
+ @abstractmethod
+ def render(self, template_string: str, request: HttpRequest) -> str: ...
-class ScriptTagTestBase(ScriptTestBase):
- def make_context(self, request):
- return Context({"request": request})
- def make_template(self, tpl):
- return Template(tpl)
+class ScriptTagTestBase(ScriptTestBase):
+ def render(self, template_string: str, request: HttpRequest) -> str:
+ context = Context({"request": request})
+ template = Template(template_string)
+ return template.render(context)
class ScriptExtensionTestBase(ScriptTestBase):
- def make_context(self, request):
- return {"request": request}
-
- def make_template(self, tpl):
- return JINJA_ENV.from_string(tpl)
+ def render(self, template_string: str, request: HttpRequest) -> str:
+ context = {"request": request}
+ template = JINJA_ENV.from_string(template_string)
+ return template.render(context)
diff --git a/csp/utils.py b/csp/utils.py
index e02ed0e..fe35633 100644
--- a/csp/utils.py
+++ b/csp/utils.py
@@ -2,6 +2,7 @@
import re
from collections import OrderedDict
from itertools import chain
+from typing import Any, Dict, Optional, Union, Callable
from django.conf import settings
from django.utils.encoding import force_str
@@ -49,8 +50,10 @@
"block-all-mixed-content": None, # Deprecated.
}
+_DIRECTIVES = Dict[str, Any]
-def default_config(csp):
+
+def default_config(csp: Optional[_DIRECTIVES]) -> Optional[_DIRECTIVES]:
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.
@@ -60,7 +63,13 @@ def default_config(csp):
return config
-def build_policy(config=None, update=None, replace=None, nonce=None, report_only=False):
+def build_policy(
+ config: Optional[_DIRECTIVES] = None,
+ update: Optional[_DIRECTIVES] = None,
+ replace: Optional[_DIRECTIVES] = None,
+ nonce: Optional[str] = None,
+ report_only: bool = False,
+) -> str:
"""Builds the policy as a string from the settings."""
if config is None:
@@ -126,14 +135,14 @@ def build_policy(config=None, update=None, replace=None, nonce=None, report_only
return "; ".join([f"{k} {val}".strip() for k, val in policy_parts.items()])
-def _default_attr_mapper(attr_name, val):
+def _default_attr_mapper(attr_name: str, val: str) -> str:
if val:
return f' {attr_name}="{val}"'
else:
return ""
-def _bool_attr_mapper(attr_name, val):
+def _bool_attr_mapper(attr_name: str, val: bool) -> str:
# Only return the bare word if the value is truthy
# ie - defer=False should actually return an empty string
if val:
@@ -142,7 +151,7 @@ def _bool_attr_mapper(attr_name, val):
return ""
-def _async_attr_mapper(attr_name, val):
+def _async_attr_mapper(attr_name: str, val: Union[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."""
@@ -155,7 +164,7 @@ def _async_attr_mapper(attr_name, val):
# Allow per-attribute customization of returned string template
-SCRIPT_ATTRS = 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
@@ -179,7 +188,7 @@ def _async_attr_mapper(attr_name, val):
)
-def _unwrap_script(text):
+def _unwrap_script(text: str) -> str:
"""Extract content defined between script tags"""
matches = re.search(_script_tag_contents_re, text)
if matches and len(matches.groups()):
@@ -188,7 +197,7 @@ def _unwrap_script(text):
return text
-def build_script_tag(content=None, **kwargs):
+def build_script_tag(content: Optional[str] = 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/conf.py b/docs/conf.py
index dc1ebe9..2c490d4 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -10,7 +10,8 @@
# All configuration values have a default; values that are commented out
# serve to show the default.
-import pkg_resources
+from importlib.metadata import version as get_version
+from typing import Dict
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
@@ -50,7 +51,7 @@
# built documents.
#
# The short X.Y version.
-version = pkg_resources.get_distribution("django_csp").version
+version = get_version("django_csp")
# The full version, including alpha/beta/rc tags.
release = version
@@ -122,7 +123,7 @@
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ["_static"]
+# html_static_path = ["_static"]
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
@@ -171,7 +172,7 @@
# -- Options for LaTeX output --------------------------------------------------
-latex_elements = {
+latex_elements: Dict[str, str] = {
# The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
diff --git a/docs/contributing.rst b/docs/contributing.rst
index e48df57..254287e 100644
--- a/docs/contributing.rst
+++ b/docs/contributing.rst
@@ -8,6 +8,22 @@ Patches are more than welcome! You can find the issue tracker `on GitHub
`_ and we'd love pull
requests.
+Setup
+=====
+To install all the requirements (probably into a virtualenv_):
+
+.. code-block:: bash
+
+ pip install -e .
+ pip install -e ".[dev]"
+
+This installs:
+
+* All the text requirements
+* All the typing requirements
+* pre-commit_, for checking styles
+* tox_, for running tests against multiple environments
+* Sphinx_ and document building requirements
Style
=====
@@ -44,9 +60,55 @@ To run the tests with coverage and get a report, use the following command:
pytest --cov=csp --cov-config=.coveragerc
+To run the tests like Github Actions does, you'll need pyenv_:
+
+.. code-block:: bash
+
+ pyenv install 3.8 3.9 3.10 3.11 3.12 pypy3.8 pypy3.9 pypy3.10
+ pyenv local 3.8 3.9 3.10. 3.11 3.12 pypy3.8 pypy3.9 pypy3.10
+ pip install -e ".[dev]" # installs tox
+ tox # run sequentially
+ tox run-parallel # run in parallel, may cause issues on coverage step
+ tox -e 3.12-4.2.x # run tests on Python 3.12 and Django 4.x
+ tox --listenvs # list all the environments
+
+Type Checking
+=============
+
+New code should have type annotations and pass mypy_ in strict mode. Use the
+typing syntax available in the earliest supported Python version 3.8.
+
+To check types:
+
+.. code-block:: bash
+
+ pip install -e ".[typing]"
+ mypy .
+
+If you make a lot of changes, it can help to clear the mypy cache:
+
+.. code-block:: bash
+
+ mypy --no-incremental .
+
+Updating Documentation
+======================
+
+To rebuild documentation locally:
+
+.. code-block:: bash
+
+ pip install -e ".[dev]"
+ cd docs
+ make html
+ open _build/html/index.html # On macOS
.. _PEP8: http://www.python.org/dev/peps/pep-0008/
+.. _Sphinx: https://www.sphinx-doc.org/en/master/index.html
+.. _mypy: https://mypy.readthedocs.io/en/stable/
+.. _pre-commit: https://pre-commit.com/#install
+.. _pyenv: https://github.com/pyenv/pyenv
+.. _pytest: https://pytest.org/latest/usage.html
.. _ruff: https://pypi.org/project/ruff/
+.. _tox: https://tox.wiki/en/stable/
.. _virtualenv: http://www.virtualenv.org/
-.. _pytest: https://pytest.org/latest/usage.html
-.. _pre-commit: https://pre-commit.com/#install
diff --git a/pyproject.toml b/pyproject.toml
index 677c19d..78ceec9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -20,7 +20,6 @@ classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Web Environment",
"Environment :: Web Environment :: Mozilla",
- "Framework :: Django :: 3.2",
"Framework :: Django :: 4.2",
"Framework :: Django :: 5.0",
"Intended Audience :: Developers",
@@ -38,7 +37,22 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = [
- "django>=3.2",
+ "django>=4.2",
+]
+optional-dependencies.dev = [
+ "django-stubs[compatible-mypy]",
+ "jinja2>=2.9.6",
+ "mypy",
+ "pre-commit",
+ "pytest",
+ "pytest-cov",
+ "pytest-django",
+ "pytest-ruff",
+ "Sphinx",
+ "sphinx_rtd_theme",
+ "tox",
+ "tox-gh-actions",
+ "types-setuptools",
]
optional-dependencies.jinja2 = [
"jinja2>=2.9.6",
@@ -50,8 +64,16 @@ optional-dependencies.tests = [
"pytest-django",
"pytest-ruff",
]
+optional-dependencies.typing = [
+ "django-stubs[compatible-mypy]",
+ "jinja2>=2.9.6",
+ "mypy",
+ "pytest",
+ "pytest-django",
+ "types-setuptools",
+]
urls."Bug Tracker" = "https://github.com/mozilla/django-csp/issues"
-urls.Changelog = "https://github.com/mozilla/django-csp/blob/main/CHANGES"
+urls.Changelog = "https://github.com/mozilla/django-csp/blob/main/CHANGES.md"
urls.Documentation = "http://django-csp.readthedocs.org/"
urls.Homepage = "http://github.com/mozilla/django-csp"
urls."Source Code" = "https://github.com/mozilla/django-csp"
@@ -66,3 +88,11 @@ find = { namespaces = false }
[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']
+strict = true
+
+[tool.django-stubs]
+django_settings_module = "csp.tests.settings"
diff --git a/tox.ini b/tox.ini
index a5b476c..b993c17 100644
--- a/tox.ini
+++ b/tox.ini
@@ -4,17 +4,22 @@ envlist =
{3.10,3.11,3.12,pypy310}-main
{3.10,3.11,3.12,pypy310}-5.0.x
{3.8,3.9,3.10,3.11,3.12,pypy38,pypy39,pypy310}-4.2.x
- {3.8,3.9,3.10,pypy38,pypy39,pypy310}-3.2.x
+ {3.8,3.9,3.10,3.11,3.12,pypy38,pypy39,pypy310}-types
# Don't run coverage when testing with pypy:
# see https://github.com/nedbat/coveragepy/issues/1382
-[testenv:pypy310-main,pypy310-5.0.x,{pypy38,pypy39,pypy310}-4.2.x,{pypy38,pypy39,pypy310}-3.2.x]
+[testenv:pypy310-main,pypy310-5.0.x,{pypy38,pypy39,pypy310}-4.2.x]
commands =
pip install --upgrade pip
pip install -e .[tests]
pytest {toxinidir}/csp
+[testenv:{3.8,3.9,3.10,3.11,3.12,pypy38,pypy39,pypy310}-types]
+commands =
+ pip install --upgrade pip
+ pip install -e .[typing]
+ mypy --cache-dir {temp_dir}/.mypy_cache {toxinidir}/csp
[testenv]
setenv =
@@ -32,7 +37,9 @@ basepython =
3.10: python3.10
3.11: python3.11
3.12: python3.12
- pypy3: pypy3
+ pypy38: pypy3.8
+ pypy39: pypy3.9
+ pypy310: pypy3.10
deps =
pytest
@@ -45,11 +52,11 @@ deps =
# Running tox in GHA without redefining it all in a GHA matrix:
# https://github.com/ymyzk/tox-gh-actions
python =
- 3.8: py38
- 3.9: py39
- 3.10: py310
- 3.11: py311
- 3.12: py312
+ 3.8: 3.8
+ 3.9: 3.9
+ 3.10: 3.10
+ 3.11: 3.11
+ 3.12: 3.12
pypy-3.8: pypy38
pypy-3.9: pypy39
pypy-3.10: pypy310