New Upstream Release - python-whitenoise

Ready changes

Summary

Merged new upstream version: 6.4.0 (was: 6.2.0).

Diff

diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..40f4ac7
--- /dev/null
+++ b/.github/CODE_OF_CONDUCT.md
@@ -0,0 +1 @@
+This project follows [Django's Code of Conduct](https://www.djangoproject.com/conduct/).
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..3ba13e0
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1 @@
+blank_issues_enabled: false
diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml
new file mode 100644
index 0000000..2a7f84f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature-request.yml
@@ -0,0 +1,10 @@
+name: Feature Request
+description: Request an enhancement or new feature.
+body:
+- type: textarea
+  id: description
+  attributes:
+    label: Description
+    description: Please describe your feature request with appropriate detail.
+  validations:
+    required: true
diff --git a/.github/ISSUE_TEMPLATE/issue.yml b/.github/ISSUE_TEMPLATE/issue.yml
new file mode 100644
index 0000000..26d8ce4
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/issue.yml
@@ -0,0 +1,34 @@
+name: Issue
+description: File an issue
+body:
+- type: input
+  id: python_version
+  attributes:
+    label: Python Version
+    description: Which version of Python were you using?
+    placeholder: 3.9.0
+  validations:
+    required: false
+- type: input
+  id: django_version
+  attributes:
+    label: Django Version
+    description: Which version of Django were you using?
+    placeholder: 3.2.0
+  validations:
+    required: false
+- type: input
+  id: package_version
+  attributes:
+    label: Package Version
+    description: Which version of this package were you using? If not the latest version, please check this issue has not since been resolved.
+    placeholder: 1.0.0
+  validations:
+    required: false
+- type: textarea
+  id: description
+  attributes:
+    label: Description
+    description: Please describe your issue.
+  validations:
+    required: true
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
new file mode 100644
index 0000000..205ab9a
--- /dev/null
+++ b/.github/SECURITY.md
@@ -0,0 +1 @@
+Please report security issues directly over email to me@adamj.eu
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..e5903e0
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,6 @@
+version: 2
+updates:
+- package-ecosystem: github-actions
+  directory: "/"
+  schedule:
+    interval: weekly
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 36d3bcb..ca9883e 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -3,7 +3,7 @@ name: CI
 on:
   push:
     branches:
-    - master
+    - main
   pull_request:
 
 concurrency:
@@ -18,18 +18,19 @@ jobs:
     strategy:
       matrix:
         os:
-        - ubuntu-20.04
+        - ubuntu-22.04
         - windows-2022
         python-version:
-        - "3.7"
-        - "3.8"
-        - "3.9"
-        - "3.10"
+        - '3.7'
+        - '3.8'
+        - '3.9'
+        - '3.10'
+        - '3.11'
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
 
-    - uses: actions/setup-python@v2
+    - uses: actions/setup-python@v4
       with:
         python-version: ${{ matrix.python-version }}
         cache: pip
@@ -38,34 +39,34 @@ jobs:
     - name: Install dependencies
       run: |
         python -m pip install --upgrade pip setuptools wheel
-        python -m pip install --upgrade tox tox-py
+        python -m pip install --upgrade 'tox>=4.0.0rc3'
 
     - name: Run tox targets for ${{ matrix.python-version }}
-      run: tox --py current
+      run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d .)
 
     - name: Upload coverage data
       if: matrix.os != 'windows-2022'
-      uses: actions/upload-artifact@v2
+      uses: actions/upload-artifact@v3
       with:
         name: coverage-data
         path: '.coverage.*'
 
   coverage:
     name: Coverage
-    runs-on: ubuntu-20.04
+    runs-on: ubuntu-22.04
     needs: tests
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
 
-      - uses: actions/setup-python@v2
+      - uses: actions/setup-python@v4
         with:
-          python-version: '3.10'
+          python-version: '3.11'
 
       - name: Install dependencies
         run: python -m pip install --upgrade coverage[toml]
 
       - name: Download data
-        uses: actions/download-artifact@v2
+        uses: actions/download-artifact@v3
         with:
           name: coverage-data
 
@@ -77,7 +78,7 @@ jobs:
 
       - name: Upload HTML report
         if: ${{ failure() }}
-        uses: actions/upload-artifact@v2
+        uses: actions/upload-artifact@v3
         with:
           name: html-report
           path: htmlcov
diff --git a/.gitignore b/.gitignore
index d971bf8..db70699 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,8 @@
-*.py[co]
-__pycache__
-/MANIFEST
-/.tox
+*.egg-info/
+*.pyc
 /.coverage
 /.coverage.*
-/htmlcov
-/docs/_build
-/dist
-/src/*.egg-info
+/.tox
+/build/
+/dist/
+/docs/_build/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 91e8e93..5e20c84 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,9 +1,9 @@
 default_language_version:
-  python: python3.10
+  python: python3.11
 
 repos:
 - repo: https://github.com/pre-commit/pre-commit-hooks
-  rev: v4.1.0
+  rev: v4.4.0
   hooks:
   - id: check-added-large-files
   - id: check-case-conflict
@@ -13,27 +13,43 @@ repos:
   - id: check-toml
   - id: end-of-file-fixer
   - id: trailing-whitespace
+- repo: https://github.com/tox-dev/pyproject-fmt
+  rev: 0.9.1
+  hooks:
+  - id: pyproject-fmt
+- repo: https://github.com/asottile/setup-cfg-fmt
+  rev: v2.2.0
+  hooks:
+  - id: setup-cfg-fmt
+    args:
+    - --include-version-classifiers
 - repo: https://github.com/asottile/pyupgrade
-  rev: v2.31.0
+  rev: v3.3.1
   hooks:
   - id: pyupgrade
     args: [--py37-plus]
 - repo: https://github.com/psf/black
-  rev: 22.1.0
+  rev: 23.1.0
   hooks:
   - id: black
-- repo: https://github.com/asottile/blacken-docs
-  rev: v1.12.1
+- repo: https://github.com/adamchainz/blacken-docs
+  rev: 1.13.0
   hooks:
   - id: blacken-docs
     additional_dependencies:
-    - black==22.1.0
-- repo: https://github.com/pycqa/isort
-  rev: 5.10.1
+    - black==23.1.0
+- repo: https://github.com/asottile/reorder_python_imports
+  rev: v3.9.0
   hooks:
-  - id: isort
+  - id: reorder-python-imports
+    args:
+    - --py37-plus
+    - --application-directories
+    - .:example:src
+    - --add-import
+    - 'from __future__ import annotations'
 - repo: https://github.com/PyCQA/flake8
-  rev: 4.0.1
+  rev: 6.0.0
   hooks:
   - id: flake8
     additional_dependencies:
@@ -41,8 +57,3 @@ repos:
     - flake8-comprehensions
     - flake8-tidy-imports
     - flake8-typing-imports
-- repo: https://github.com/mgedmin/check-manifest
-  rev: "0.47"
-  hooks:
-  - id: check-manifest
-    args: [--no-build-isolation]
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index f497b49..c05795f 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -4,12 +4,13 @@
 version: 2
 
 build:
-  os: ubuntu-20.04
+  os: ubuntu-22.04
   tools:
-    python: "3.10"
+    python: "3.11"
 
 sphinx:
   configuration: docs/conf.py
+  fail_on_warning: true
 
 formats: all
 
diff --git a/MANIFEST.in b/MANIFEST.in
index a0cc8c9..a57e5db 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,14 +1,7 @@
-global-exclude *.py[cod]
-prune __pycache__
 prune docs
-prune requirements
 prune scripts
-prune tests
-exclude .editorconfig
-exclude .pre-commit-config.yaml
 exclude .readthedocs.yaml
 exclude CHANGELOG.rst
-exclude tox.ini
 include LICENSE
 include pyproject.toml
 include README.rst
diff --git a/README.rst b/README.rst
index 57464c4..e233c11 100644
--- a/README.rst
+++ b/README.rst
@@ -1,25 +1,25 @@
+==========
 WhiteNoise
 ==========
 
-.. image:: https://img.shields.io/travis/evansd/whitenoise.svg
-   :target:  https://travis-ci.org/evansd/whitenoise
-   :alt: Build Status (Linux)
+.. image:: https://img.shields.io/readthedocs/whitenoise?style=for-the-badge
+   :target: https://whitenoise.evans.io/en/latest/
+
+.. image:: https://img.shields.io/github/actions/workflow/status/evansd/whitenoise/main.yml?branch=master&style=for-the-badge
+   :target: https://github.com/evansd/whitenoise/actions?workflow=CI
 
-.. image:: https://img.shields.io/appveyor/ci/evansd/whitenoise.svg
-   :target:  https://ci.appveyor.com/project/evansd/whitenoise
-   :alt: Build Status (Windows)
+.. image:: https://img.shields.io/badge/Coverage-96%25-success?style=for-the-badge
+   :target: https://github.com/evansd/whitenoise/actions?workflow=CI
 
-.. image:: https://img.shields.io/pypi/v/whitenoise.svg
-    :target: https://pypi.python.org/pypi/whitenoise
-    :alt: Latest PyPI version
+.. image:: https://img.shields.io/pypi/v/whitenoise.svg?style=for-the-badge
+   :target: https://pypi.org/project/whitenoise/
 
-.. image:: https://img.shields.io/pypi/dm/whitenoise.svg
-    :target: https://pypistats.org/packages/whitenoise
-    :alt: Monthly PyPI downloads
+.. image:: https://img.shields.io/badge/code%20style-black-000000.svg?style=for-the-badge
+   :target: https://github.com/psf/black
 
-.. image:: https://img.shields.io/github/stars/evansd/whitenoise.svg?style=social&label=Star
-    :target: https://github.com/evansd/whitenoise
-    :alt: GitHub project
+.. image:: https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white&style=for-the-badge
+   :target: https://github.com/pre-commit/pre-commit
+   :alt: pre-commit
 
 **Radically simplified static file serving for Python web apps**
 
diff --git a/debian/changelog b/debian/changelog
index 9c6d723..afc5df3 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,10 @@
+python-whitenoise (6.4.0-1) UNRELEASED; urgency=low
+
+  * New upstream release.
+  * New upstream release.
+
+ -- Debian Janitor <janitor@jelmer.uk>  Sat, 20 May 2023 14:10:40 -0000
+
 python-whitenoise (6.0.0-3) unstable; urgency=medium
 
   [ Debian Janitor ]
diff --git a/docs/base.rst b/docs/base.rst
index ea3be04..b0b272a 100644
--- a/docs/base.rst
+++ b/docs/base.rst
@@ -185,6 +185,7 @@ sub-classing WhiteNoise and setting the attributes directly.
     long enough that, if you're running WhiteNoise behind a CDN, the CDN will still take
     the majority of the strain during times of heavy load.
 
+    Set to ``None`` to disable setting any ``Cache-Control`` header on non-versioned files.
 
 .. attribute:: index_file
 
@@ -193,7 +194,6 @@ sub-classing WhiteNoise and setting the attributes directly.
     If ``True`` enable :ref:`index file serving <index_files>`. If set to a non-empty
     string, enable index files and use that string as the index file name.
 
-
 .. attribute:: mimetypes
 
     :default: ``None``
@@ -206,7 +206,7 @@ sub-classing WhiteNoise and setting the attributes directly.
     Note that WhiteNoise ships with its own default set of mimetypes and does
     not use the system-supplied ones (e.g. ``/etc/mime.types``). This ensures
     that it behaves consistently regardless of the environment in which it's
-    run.  View the defaults in the :file:`media_types.py
+    run.  View the defaults in the :ghfile:`media_types.py
     <whitenoise/media_types.py>` file.
 
     In addition to file extensions, mimetypes can be specified by supplying the entire
diff --git a/docs/changelog.rst b/docs/changelog.rst
index fff2086..5bd9c90 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -1,8 +1,47 @@
+=========
 Changelog
 =========
 
-6.0.0
------
+6.4.0 (2023-02-25)
+------------------
+
+* Support Django 4.2.
+
+* Remove further support for byte strings from the ``root`` and ``prefix`` arguments to ``WhiteNoise``, and Django’s ``STATIC_ROOT`` setting.
+  Like in the previous release, this seems to be a remnant of Python 2 support.
+  Again, this change may be backwards incompatible for a small number of projects, but it’s unlikely.
+  Django does not support ``STATIC_ROOT`` being a byte string.
+
+6.3.0 (2023-01-03)
+------------------
+
+* Add some video file extensions to be ignored during compression.
+  Since such files are already heavily compressed, further compression rarely helps.
+
+  Thanks to Jon Ribbens in `PR #431 <https://github.com/evansd/whitenoise/pull/431>`__.
+
+* Remove the behaviour of decoding byte strings passed for settings that take strings.
+  This seemed to be left around from supporting Python 2.
+  This change may be backwards incompatible for a small number of projects.
+
+* Document “hidden” feature of setting ``max_age`` to ``None`` to disable the ``Cache-Control`` header.
+
+* Drop support for working as old-style Django middleware, as support was `removed in Django 2.0 <https://docs.djangoproject.com/en/dev/releases/2.0/#features-removed-in-2-0>`__.
+
+6.2.0 (2022-06-05)
+------------------
+
+* Support Python 3.11.
+
+* Support Django 4.1.
+
+6.1.0 (2022-05-10)
+------------------
+
+* Drop support for Django 2.2, 3.0, and 3.1.
+
+6.0.0 (2022-02-10)
+------------------
 
 * Drop support for Python 3.5 and 3.6.
 
@@ -16,16 +55,12 @@ Changelog
 
   - ``.avif`` files are now served with the ``image/avif`` MIME type.
 
-  - Open Document files with extensions ``.odg``, ``.odp``, ``.ods``, and
-    ``.odt`` are now served with their respective
-    ``application/vnd.oasis.opendocument.*`` MIME types.
+  - Open Document files with extensions ``.odg``, ``.odp``, ``.ods``, and ``.odt`` are now served with their respective ``application/vnd.oasis.opendocument.*`` MIME types.
 
-* The ``whitenoise.__version__`` attribute has been removed. Use
-  ``importlib.metadata.version()`` to check the version of Whitenoise if you
-  need to.
+* The ``whitenoise.__version__`` attribute has been removed.
+  Use ``importlib.metadata.version()`` to check the version of Whitenoise if you need to.
 
-* Requests using the ``Range`` header can no longer read beyond the end of the
-  requested range.
+* Requests using the ``Range`` header can no longer read beyond the end of the requested range.
 
   Thanks to Richard Tibbles in `PR #322 <https://github.com/evansd/whitenoise/pull/322>`__.
 
@@ -36,36 +71,32 @@ Changelog
 5.3.0 (2021-07-16)
 ------------------
 
-* Gracefully handle unparsable If-Modified-Since headers (thanks
-  `@danielegozzi <https://github.com/danielegozzi>`_).
+* Gracefully handle unparsable ``If-Modified-Since`` headers (thanks `@danielegozzi <https://github.com/danielegozzi>`_).
+
 * Test against Django 3.2 (thanks `@jhnbkr <https://github.com/jhnbkr>`_).
-* Add mimetype for Markdown (``.md``) files (thanks `@bz2
-  <https://github.com/bz2>`_).
-* Various documentation improvements (thanks `@PeterJCLaw
-  <https://github.com/PeterJCLaw>`_ and `@AliRn76
-  <https://github.com/AliRn76>`_).
+
+* Add mimetype for Markdown (``.md``) files (thanks `@bz2 <https://github.com/bz2>`_).
+
+* Various documentation improvements (thanks `@PeterJCLaw <https://github.com/PeterJCLaw>`_ and `@AliRn76 <https://github.com/AliRn76>`_).
 
 5.2.0 (2020-08-04)
 ------------------
 
-* Add support for `relative STATIC_URLs <https://docs.djangoproject.com/en/3.1/ref/settings/#std:setting-STATIC_URL>`_
-  in settings, as allowed in Django 3.1.
-* Add mimetype for ``.mjs`` (JavaScript module) files and use recommended
-  ``text/javascript`` mimetype for ``.js`` files (thanks `@hanswilw <https://github.com/hanswilw>`_).
+* Add support for `relative STATIC_URLs <https://docs.djangoproject.com/en/3.1/ref/settings/#std:setting-STATIC_URL>`_ in settings, as allowed in Django 3.1.
+
+* Add mimetype for ``.mjs`` (JavaScript module) files and use recommended ``text/javascript`` mimetype for ``.js`` files (thanks `@hanswilw <https://github.com/hanswilw>`_).
+
 * Various documentation improvements (thanks `@lukeburden <https://github.com/lukeburden>`_).
 
 5.1.0 (2020-05-20)
 ------------------
 
-* Add a :any:`manifest_strict <WHITENOISE_MANIFEST_STRICT>` setting to prevent
-  Django throwing errors when missing files are referenced (thanks
-  `@MegacoderKim <https://github.com/MegacoderKim>`_).
+* Add a :any:`manifest_strict <WHITENOISE_MANIFEST_STRICT>` setting to prevent Django throwing errors when missing files are referenced (thanks `@MegacoderKim <https://github.com/MegacoderKim>`_).
 
 5.0.1 (2019-12-12)
 ------------------
 
-* Fix packaging to indicate only Python 3.5+ compatibiity (thanks `@mdalp
-  <https://github.com/mdalp>`_).
+* Fix packaging to indicate only Python 3.5+ compatibiity (thanks `@mdalp <https://github.com/mdalp>`_).
 
 5.0 (2019-12-10)
 ----------------
@@ -78,60 +109,52 @@ Changelog
 
 Other changes include:
 
-* Fix incompatibility with Django 3.0 which caused problems with Safari
-  (details `here <https://github.com/evansd/whitenoise/issues/240>`_). Thanks
-  `@paltman <https://github.com/paltman>`_ and `@giilby
-  <https://github.com/giilby>`_ for diagnosing.
+* Fix incompatibility with Django 3.0 which caused problems with Safari (details `here <https://github.com/evansd/whitenoise/issues/240>`_).
+  Thanks `@paltman <https://github.com/paltman>`_ and `@giilby <https://github.com/giilby>`_ for diagnosing.
+
 * Lots of improvements to the test suite (including switching to py.test).
-  Thanks `@NDevox <https://github.com/ndevox>`_ and `@Djailla
-  <https://github.com/djailla>`_.
+  Thanks `@NDevox <https://github.com/ndevox>`_ and `@Djailla <https://github.com/djailla>`_.
 
 4.1.4 (2019-09-24)
 ------------------
 
 * Make tests more deterministic and easier to run outside of ``tox``.
+
 * Fix Fedora packaging `issue <https://github.com/evansd/whitenoise/issues/225>`_.
+
 * Use `Black <https://github.com/psf/black>`_ to format all code.
 
 4.1.3 (2019-07-13)
 ------------------
 
-* Fix handling of zero-valued mtimes which can occur when running on some
-  filesystems (thanks `@twosigmajab <https://github.com/twosigmajab>`_ for
-  reporting).
-* Fix potential path traversal attack while running in autorefresh mode on
-  Windows (thanks `@phith0n <https://github.com/phith0n>`_ for reporting).
-  This is a good time to reiterate that autofresh mode is never intended for
-  production use.
+* Fix handling of zero-valued mtimes which can occur when running on some filesystems (thanks `@twosigmajab <https://github.com/twosigmajab>`_ for reporting).
+
+* Fix potential path traversal attack while running in autorefresh mode on Windows (thanks `@phith0n <https://github.com/phith0n>`_ for reporting).
+  This is a good time to reiterate that autofresh mode is never intended for production use.
 
 4.1.2 (2019-11-19)
 ------------------
 
-* Add correct MIME type for WebAssembly, which is required for files to be
-  executed (thanks `@mdboom <https://github.com/mdboom>`_ ).
-* Stop accessing the FILE_CHARSET Django setting which was almost entirely
-  unused and is now deprecated (thanks `@timgraham
-  <https://github.com/timgraham>`_).
+* Add correct MIME type for WebAssembly, which is required for files to be executed (thanks `@mdboom <https://github.com/mdboom>`_ ).
+
+* Stop accessing the ``FILE_CHARSET`` Django setting which was almost entirely unused and is now deprecated (thanks `@timgraham <https://github.com/timgraham>`_).
 
 4.1.1 (2018-11-12)
 ------------------
 
-* Fix `bug <https://github.com/evansd/whitenoise/issues/202>`_ in ETag
-  handling (thanks `@edmorley <https://github.com/edmorley>`_).
-* Documentation fixes (thanks `@jamesbeith <https://github.com/jamesbeith>`_
-  and `@mathieusteele <https://github.com/mathieusteele>`_).
+* Fix `bug <https://github.com/evansd/whitenoise/issues/202>`_ in ETag handling (thanks `@edmorley <https://github.com/edmorley>`_).
+
+* Documentation fixes (thanks `@jamesbeith <https://github.com/jamesbeith>`_ and `@mathieusteele <https://github.com/mathieusteele>`_).
 
 4.1 (2018-09-12)
 ----------------
 
-* Silenced spurious warning about missing directories when in development (i.e
-  "autorefresh") mode.
-* Support supplying paths as `Pathlib
-  <https://docs.python.org/3.4/library/pathlib.html>`_ instances, rather than
-  just strings (thanks `@browniebroke <https://github.com/browniebroke>`_).
-* Add a new :ref:`CompressedStaticFilesStorage <compression-and-caching>`
-  backend to support applying compression without applying Django's hash-versioning
-  process.
+* Silenced spurious warning about missing directories when in development (i.e "autorefresh") mode.
+
+* Support supplying paths as `Pathlib <https://docs.python.org/3.4/library/pathlib.html>`_ instances, rather than just strings (thanks `@browniebroke <https://github.com/browniebroke>`_).
+
+* Add a new :ref:`CompressedStaticFilesStorage <compression-and-caching>` backend to support applying compression without applying Django's hash-versioning process.
+
 * Documentation improvements.
 
 4.0 (2018-08-10)
@@ -266,35 +289,34 @@ installing WhiteNoise and Brotli together like this:
 3.3.1 (2017-09-23)
 ------------------
 
-* Fix issue with the immutable file test when running behind a CDN which rewrites
-  paths (thanks @lskillen).
+* Fix issue with the immutable file test when running behind a CDN which rewrites paths (thanks @lskillen).
 
 3.3.0 (2017-01-26)
 ------------------
 
-* Support the new `immutable <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#Revalidation_and_reloading>`_
-  Cache-Control header. This gives better caching behaviour for immutable resources than
-  simply setting a large max age.
+* Support the new `immutable <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#Revalidation_and_reloading>`_ Cache-Control header.
+  This gives better caching behaviour for immutable resources than simply setting a large max age.
 
 3.2.3 (2017-01-04)
 ------------------
 
 * Gracefully handle invalid byte sequences in URLs.
+
 * Gracefully handle filenames which are too long for the filesystem.
+
 * Send correct Content-Type for Adobe's ``crossdomain.xml`` files.
 
 3.2.2 (2016-09-26)
 ------------------
 
-* Convert any config values supplied as byte strings to text to avoid
-  runtime encoding errors when encountering non-ASCII filenames.
+* Convert any config values supplied as byte strings to text to avoid runtime encoding errors when encountering non-ASCII filenames.
 
 3.2.1 (2016-08-09)
 ------------------
 
 * Handle non-ASCII URLs correctly when using the ``wsgi.py`` integration.
-* Fix exception triggered when a static files "finder" returned a directory
-  rather than a file.
+
+* Fix exception triggered when a static files "finder" returned a directory rather than a file.
 
 3.2 (2016-05-27)
 ----------------
@@ -302,8 +324,10 @@ installing WhiteNoise and Brotli together like this:
 * Add support for the new-style middleware classes introduced in Django 1.10.
   The same WhiteNoiseMiddleware class can now be used in either the old
   ``MIDDLEWARE_CLASSES`` list or the new ``MIDDLEWARE`` list.
+
 * Fixed a bug where incorrect Content-Type headers were being sent on 304 Not
   Modified responses (thanks `@oppianmatt <https://github.com/oppianmatt>`_).
+
 * Return Vary and Cache-Control headers on 304 responses, as specified by the
   `RFC <https://tools.ietf.org/html/rfc7232#section-4.1>`_.
 
@@ -313,6 +337,7 @@ installing WhiteNoise and Brotli together like this:
 * Add new :any:`WHITENOISE_STATIC_PREFIX` setting to give flexibility in
   supporting non-standard deployment configurations e.g. serving the
   application somewhere other than the domain root.
+
 * Fix bytes/unicode bug when running with Django 1.10 on Python 2.7
 
 3.0 (2016-03-23)
@@ -324,15 +349,20 @@ installing WhiteNoise and Brotli together like this:
 
 * The setting ``WHITENOISE_GZIP_EXCLUDE_EXTENSIONS`` has been renamed to
   ``WHITENOISE_SKIP_COMPRESS_EXTENSIONS``.
+
 * The CLI :ref:`compression utility <cli-utility>` has moved from ``python -m whitenoise.gzip``
   to ``python -m whitenoise.compress``.
+
 * The now redundant ``gzipstatic`` management command has been removed.
+
 * WhiteNoise no longer uses the system mimetypes files, so if you are serving
   particularly obscure filetypes you may need to add their mimetypes explicitly
   using the new :any:`mimetypes <WHITENOISE_MIMETYPES>` setting.
+
 * Older versions of Django (1.4-1.7) and Python (2.6) are no longer supported.
   If you need support for these platforms you can continue to use `WhiteNoise
   2.x`_.
+
 * The ``whitenoise.django.GzipManifestStaticFilesStorage`` storage backend
   has been moved to
   ``whitenoise.storage.CompressedManifestStaticFilesStorage``.  The old
@@ -386,8 +416,7 @@ needed.
 
 .. rubric:: Thanks
 
-A big thank-you to `Ed Morley <https://github.com/edmorley>`_ and `Tim Graham
-<https://github.com/timgraham>`_ for their contributions to this release.
+A big thank-you to `Ed Morley <https://github.com/edmorley>`_ and `Tim Graham <https://github.com/timgraham>`_ for their contributions to this release.
 
 2.0.6 (2015-11-15)
 ------------------
@@ -407,26 +436,32 @@ A big thank-you to `Ed Morley <https://github.com/edmorley>`_ and `Tim Graham
 2.0.3 (2015-08-18)
 ------------------
 
-* Add `__version__` attribute.
+* Add ``__version__`` attribute.
 
 2.0.2 (2015-07-03)
 ------------------
 
-* More helpful error message when STATIC_URL is set to the root of a domain (thanks @dominicrodger).
+* More helpful error message when ``STATIC_URL`` is set to the root of a domain (thanks @dominicrodger).
 
 2.0.1 (2015-06-28)
 ------------------
 
 * Add support for Python 2.6.
-* Add a more helpful error message when attempting to import DjangoWhiteNoise before `DJANGO_SETTINGS_MODULE` is defined.
+
+* Add a more helpful error message when attempting to import DjangoWhiteNoise before ``DJANGO_SETTINGS_MODULE`` is defined.
 
 2.0 (2015-06-20)
 ----------------
 
-* Add an `autorefresh` mode which picks up changes to static files made after application startup (for use in development).
-* Add a `use_finders` mode for DjangoWhiteNoise which finds files in their original directories without needing them collected in `STATIC_ROOT` (for use in development). Note, this is only useful if you don't want to use Django's default runserver behaviour.
-* Remove the `follow_symlinks` argument from `add_files` and now always follow symlinks.
+* Add an ``autorefresh`` mode which picks up changes to static files made after application startup (for use in development).
+
+* Add a ``use_finders`` mode for DjangoWhiteNoise which finds files in their original directories without needing them collected in ``STATIC_ROOT`` (for use in development).
+  Note, this is only useful if you don't want to use Django's default runserver behaviour.
+
+* Remove the ``follow_symlinks`` argument from ``add_files`` and now always follow symlinks.
+
 * Support extra mimetypes which Python doesn't know about by default (including .woff2 format)
+
 * Some internal refactoring. Note, if you subclass WhiteNoise to add custom behaviour you may need to make some small changes to your code.
 
 1.0.6 (2014-12-12)
@@ -443,7 +478,9 @@ A big thank-you to `Ed Morley <https://github.com/edmorley>`_ and `Tim Graham
 ------------------
 
 * Don't attempt to gzip ``.woff`` files as they're already compressed.
+
 * Base decision to gzip on compression ratio achieved, so we don't incur gzip overhead just to save a few bytes.
+
 * More helpful error message from ``collectstatic`` if CSS files reference missing assets.
 
 1.0.3 (2014-06-08)
@@ -460,6 +497,7 @@ A big thank-you to `Ed Morley <https://github.com/edmorley>`_ and `Tim Graham
 ------------------
 
 * Fix path-to-URL conversion for Windows.
+
 * Remove cruft from packaging manifest.
 
 1.0 (2014-04-14)
diff --git a/docs/conf.py b/docs/conf.py
index 80584a9..e3fe066 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,16 +1,3 @@
-# flake8: noqa
-#
-# WhiteNoise documentation build configuration file, created by
-# sphinx-quickstart on Sun Aug 11 15:22:49 2013.
-#
-# This file is execfile()d with the current directory set to its containing dir.
-#
-# Note that not all possible configuration values are present in this
-# autogenerated file.
-#
-# All configuration values have a default; values that are commented out
-# serve to show the default.
-
 from __future__ import annotations
 
 import datetime
@@ -32,9 +19,6 @@ sys.path.insert(0, os.path.join(project_root, "src"))
 
 # -- General configuration -----------------------------------------------------
 
-# If your documentation needs a minimal Sphinx version, state it here.
-# needs_sphinx = '1.0'
-
 # Add any Sphinx extension module names here, as strings. They can be extensions
 # coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
 extensions = [
@@ -79,16 +63,6 @@ version = _get_version()
 # The full version, including alpha/beta/rc tags.
 release = version
 
-# The language for content autogenerated by Sphinx. Refer to documentation
-# for a list of supported languages.
-# language = None
-
-# There are two options for replacing |today|: either, you set today to some
-# non-false value, then it is used:
-# today = ''
-# Else, today_fmt is used as the format for a strftime call.
-# today_fmt = '%B %d, %Y'
-
 # List of patterns, relative to source directory, that match files and
 # directories to ignore when looking for source files.
 exclude_patterns = [
@@ -96,156 +70,43 @@ exclude_patterns = [
     "venv",
 ]
 
-# The reST default role (used for this markup: `text`) to use for all documents.
-# default_role = None
-
-# If true, '()' will be appended to :func: etc. cross-reference text.
-# add_function_parentheses = True
-
-# If true, the current module name will be prepended to all description
-# unit titles (such as .. function::).
-# add_module_names = True
-
-# If true, sectionauthor and moduleauthor directives will be shown in the
-# output. They are ignored by default.
-# show_authors = False
-
 # The name of the Pygments (syntax highlighting) style to use.
 pygments_style = "sphinx"
 
-# A list of ignored prefixes for module index sorting.
-# modindex_common_prefix = []
-
-
 # -- Options for HTML output ---------------------------------------------------
 
 # The theme to use for HTML and HTML Help pages.  See the documentation for
 # a list of builtin themes.
 
 html_theme = "furo"
-
-# Theme options are theme-specific and customize the look and feel of a theme
-# further.  For a list of options available for each theme, see the
-# documentation.
-# html_theme_options = {}
-
-# Add any paths that contain custom themes here, relative to this directory.
-# html_theme_path = []
-
-# The name for this set of Sphinx documents.  If None, it defaults to
-# "<project> v<release> documentation".
-# html_title = None
-
-# A shorter title for the navigation bar.  Default is the same as html_title.
-# html_short_title = None
-
-# The name of an image file (relative to this directory) to place at the top
-# of the sidebar.
-# html_logo = None
-
-# The name of an image file (within the static path) to use as favicon of the
-# docs.  This file should be a Windows icon file (.ico) being 16x16 or 32x32
-# pixels large.
-# html_favicon = None
-
-# 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"]
-
-# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
-# using the given strftime format.
-# html_last_updated_fmt = '%b %d, %Y'
-
-# If true, SmartyPants will be used to convert quotes and dashes to
-# typographically correct entities.
-# html_use_smartypants = True
-
-# Custom sidebar templates, maps document names to template names.
-# html_sidebars = {}
-
-# Additional templates that should be rendered to pages, maps page names to
-# template names.
-# html_additional_pages = {}
-
-# If false, no module index is generated.
-# html_domain_indices = True
-
-# If false, no index is generated.
-# html_use_index = True
-
-# If true, the index is split into individual pages for each letter.
-# html_split_index = False
-
-# If true, links to the reST sources are added to the pages.
-# html_show_sourcelink = True
-
-# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
-# html_show_sphinx = True
-
-# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
-# html_show_copyright = True
-
-# If true, an OpenSearch description file will be output, and all pages will
-# contain a <link> tag referring to it.  The value of this option must be the
-# base URL from which the finished HTML is served.
-# html_use_opensearch = ''
-
-# This is the file name suffix for HTML files (e.g. ".xhtml").
-# html_file_suffix = None
+html_theme_options = {
+    "dark_css_variables": {
+        "admonition-font-size": "100%",
+        "admonition-title-font-size": "100%",
+    },
+    "light_css_variables": {
+        "admonition-font-size": "100%",
+        "admonition-title-font-size": "100%",
+    },
+}
 
 # Output file base name for HTML help builder.
 htmlhelp_basename = "WhiteNoisedoc"
 
-
 # -- Options for LaTeX output --------------------------------------------------
 
-latex_elements = {
-    # The paper size ('letterpaper' or 'a4paper').
-    #'papersize': 'letterpaper',
-    # The font size ('10pt', '11pt' or '12pt').
-    #'pointsize': '10pt',
-    # Additional stuff for the LaTeX preamble.
-    #'preamble': '',
-}
-
 # Grouping the document tree into LaTeX files. List of tuples
 # (source start file, target name, title, author, documentclass [howto/manual]).
 latex_documents = [
     ("index", "WhiteNoise.tex", "WhiteNoise Documentation", "David Evans", "manual")
 ]
 
-# The name of an image file (relative to this directory) to place at the top of
-# the title page.
-# latex_logo = None
-
-# For "manual" documents, if this is true, then toplevel headings are parts,
-# not chapters.
-# latex_use_parts = False
-
-# If true, show page references after internal links.
-# latex_show_pagerefs = False
-
-# If true, show URL addresses after external links.
-# latex_show_urls = False
-
-# Documents to append as an appendix to all manuals.
-# latex_appendices = []
-
-# If false, no module index is generated.
-# latex_domain_indices = True
-
-
 # -- Options for manual page output --------------------------------------------
 
 # One entry per manual page. List of tuples
 # (source start file, name, description, authors, manual section).
 man_pages = [("index", "whitenoise", "WhiteNoise Documentation", ["David Evans"], 1)]
 
-# If true, show URL addresses after external links.
-# man_show_urls = False
-
-
 # -- Options for Texinfo output ------------------------------------------------
 
 # Grouping the document tree into Texinfo files. List of tuples
@@ -263,15 +124,6 @@ texinfo_documents = [
     )
 ]
 
-# Documents to append as an appendix to all manuals.
-# texinfo_appendices = []
-
-# If false, no module index is generated.
-# texinfo_domain_indices = True
-
-# How to display URL addresses: 'footnote', 'no', or 'inline'.
-# texinfo_show_urls = 'footnote'
-
-git_tag = f"v{version}" if version != "development" else "master"
-github_base_url = f"https://github.com/evansd/whitenoise/blob/{git_tag}/"
-extlinks = {"file": (github_base_url + "%s", "")}
+git_tag = f"{version}" if version != "development" else "main"
+github_base_url = f"https://github.com/evansd/whitenoise/blob/{git_tag}/src/"
+extlinks = {"ghfile": (github_base_url + "%s", "")}
diff --git a/docs/django.rst b/docs/django.rst
index a1450b0..3b6e642 100644
--- a/docs/django.rst
+++ b/docs/django.rst
@@ -32,10 +32,10 @@ rather than writing the URL directly. For example:
 .. code-block:: django
 
    {% load static %}
-   <img src="{% static "images/hi.jpg" %}" alt="Hi!" />
+   <img src="{% static "images/hi.jpg" %}" alt="Hi!">
 
    <!-- DON'T WRITE THIS -->
-   <img src="/static/images/hi.jpg" alt="Hi!" />
+   <img src="/static/images/hi.jpg" alt="Hi!">
 
 For further details see the Django `staticfiles
 <https://docs.djangoproject.com/en/stable/howto/static-files/>`_ guide.
@@ -81,9 +81,22 @@ caching.
 3. Add compression and caching support
 --------------------------------------
 
-WhiteNoise comes with a storage backend which automatically takes care of
-compressing your files and creating unique names for each version so they can
-safely be cached forever. To use it, just add this to your ``settings.py``:
+WhiteNoise comes with a storage backend which compresses your files and hashes
+them to unique names, so they can safely be cached forever. To use it, set it
+as your staticfiles storage backend in your settings file.
+
+On Django 4.2+:
+
+.. code-block:: python
+
+    STORAGES = {
+        # ...
+        "staticfiles": {
+            "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
+        },
+    }
+
+On older Django versions:
 
 .. code-block:: python
 
@@ -91,14 +104,16 @@ safely be cached forever. To use it, just add this to your ``settings.py``:
 
 This combines automatic compression with the caching behaviour provided by
 Django's ManifestStaticFilesStorage_ backend. If you want to apply compression
-but don't want the caching behaviour then you can use:
+but don't want the caching behaviour then you can use the alternative backend:
 
 .. code-block:: python
 
-   STATICFILES_STORAGE = "whitenoise.storage.CompressedStaticFilesStorage"
+   "whitenoise.storage.CompressedStaticFilesStorage"
+
+.. note::
 
-.. note:: If you are having problems after switching to the WhiteNoise storage
-   backend please see the :ref:`troubleshooting guide <storage-troubleshoot>`.
+    If you are having problems after switching to the WhiteNoise storage
+    backend please see the :ref:`troubleshooting guide <storage-troubleshoot>`.
 
 .. _ManifestStaticFilesStorage: https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#manifeststaticfilesstorage
 
@@ -295,7 +310,7 @@ arguments upper-cased with a 'WHITENOISE\_' prefix.
 
     Time (in seconds) for which browsers and proxies should cache **non-versioned** files.
 
-    Versioned files (i.e. files which have been given a unique name like *base.a4ef2389.css* by
+    Versioned files (i.e. files which have been given a unique name like ``base.a4ef2389.css`` by
     including a hash of their contents in the name) are detected automatically and set to be
     cached forever.
 
@@ -303,6 +318,7 @@ arguments upper-cased with a 'WHITENOISE\_' prefix.
     long enough that, if you're running WhiteNoise behind a CDN, the CDN will still take
     the majority of the strain during times of heavy load.
 
+    Set to ``None`` to disable setting any ``Cache-Control`` header on non-versioned files.
 
 .. attribute:: WHITENOISE_INDEX_FILE
 
@@ -324,7 +340,7 @@ arguments upper-cased with a 'WHITENOISE\_' prefix.
     Note that WhiteNoise ships with its own default set of mimetypes and does
     not use the system-supplied ones (e.g. ``/etc/mime.types``). This ensures
     that it behaves consistently regardless of the environment in which it's
-    run.  View the defaults in the :file:`media_types.py
+    run.  View the defaults in the :ghfile:`media_types.py
     <whitenoise/media_types.py>` file.
 
     In addition to file extensions, mimetypes can be specified by supplying the entire
@@ -408,7 +424,7 @@ arguments upper-cased with a 'WHITENOISE\_' prefix.
 
 .. attribute:: WHITENOISE_IMMUTABLE_FILE_TEST
 
-    :default: See :file:`immutable_file_test <whitenoise/middleware.py#L121>` in source
+    :default: See :ghfile:`immutable_file_test <whitenoise/middleware.py#L134>` in source
 
     Reference to function, or string.
 
@@ -515,7 +531,7 @@ files after startup (unless using Django `DEBUG` mode). As such, all static
 files must be generated in advance. If you're using Django Compressor, this
 can be performed using its `offline compression`_ feature.
 
-.. _offline compression: https://django-compressor.readthedocs.io/en/latest/usage/#offline-compression
+.. _offline compression: https://django-compressor.readthedocs.io/en/stable/usage.html#offline-compression
 
 --------------------------------------------------------------------------
 
@@ -662,7 +678,7 @@ your static directory to just the files you need.
 Why do I get "ValueError: Missing staticfiles manifest entry for ..."?
 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 
-If you are seeing this error that you means you are referencing a static file in your
+If you are seeing this error that means you are referencing a static file in your
 templates (using something like ``{% static "foo" %}`` which doesn't exist, or
 at least isn't where Django expects it to be. If you don't understand why Django can't
 find the file you can use
diff --git a/docs/index.rst b/docs/index.rst
index 917cb1d..8ded314 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -1,27 +1,6 @@
 WhiteNoise
 ==========
 
-.. image:: https://img.shields.io/travis/evansd/whitenoise.svg
-   :target:  https://travis-ci.org/evansd/whitenoise
-   :alt: Build Status (Linux)
-
-.. image:: https://img.shields.io/appveyor/ci/evansd/whitenoise.svg
-   :target:  https://ci.appveyor.com/project/evansd/whitenoise
-   :alt: Build Status (Windows)
-
-.. image:: https://img.shields.io/pypi/v/whitenoise.svg
-    :target: https://pypi.python.org/pypi/whitenoise
-    :alt: Latest PyPI version
-
-.. image:: https://img.shields.io/pypi/dm/whitenoise.svg
-    :target: https://pypistats.org/packages/whitenoise
-    :alt: Monthly PyPI downloads
-
-.. image:: https://img.shields.io/github/stars/evansd/whitenoise.svg?style=social&label=Star
-    :target: https://github.com/evansd/whitenoise
-    :alt: GitHub project
-
-
 **Radically simplified static file serving for Python web apps**
 
 With a couple of lines of config WhiteNoise allows your web app to serve its
@@ -116,9 +95,9 @@ Compatibility
 -------------
 
 WhiteNoise works with any WSGI-compatible application and is tested on Python
-**3.7** – **3.10**, on both Linux and Windows.
+**3.7** – **3.11**, on both Linux and Windows.
 
-Django WhiteNoiseMiddlware is tested with Django versions **2.2** --- **4.0**
+Django WhiteNoiseMiddleware is tested with Django versions **3.2** --- **4.1**
 
 
 Endorsements
diff --git a/pyproject.toml b/pyproject.toml
index 446f713..9fb6e9f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,14 +1,12 @@
 [build-system]
-requires = ["setuptools >= 40.6.0", "wheel"]
 build-backend = "setuptools.build_meta"
+requires = [
+  "setuptools",
+]
 
 [tool.black]
 target-version = ['py37']
 
-[tool.isort]
-profile = "black"
-add_imports = "from __future__ import annotations"
-
 [tool.pytest.ini_options]
 addopts = """\
     --strict-config
diff --git a/requirements/compile.py b/requirements/compile.py
index 111b0f8..df7326b 100755
--- a/requirements/compile.py
+++ b/requirements/compile.py
@@ -9,7 +9,7 @@ from pathlib import Path
 if __name__ == "__main__":
     os.chdir(Path(__file__).parent)
     os.environ["CUSTOM_COMPILE_COMMAND"] = "requirements/compile.py"
-    os.environ.pop("PIP_REQUIRE_VIRTUALENV", None)
+    os.environ["PIP_REQUIRE_VIRTUALENV"] = "0"
     common_args = [
         "-m",
         "piptools",
@@ -22,45 +22,33 @@ if __name__ == "__main__":
             "python3.7",
             *common_args,
             "-P",
-            "Django>=2.2,<2.3",
-            "-o",
-            "py37-django22.txt",
-        ],
-        check=True,
-        capture_output=True,
-    )
-    subprocess.run(
-        [
-            "python3.7",
-            *common_args,
-            "-P",
-            "Django>=3.0a1,<3.1",
+            "Django>=3.2a1,<3.3",
             "-o",
-            "py37-django30.txt",
+            "py37-django32.txt",
         ],
         check=True,
         capture_output=True,
     )
     subprocess.run(
         [
-            "python3.7",
+            "python3.8",
             *common_args,
             "-P",
-            "Django>=3.1a1,<3.2",
+            "Django>=3.2a1,<3.3",
             "-o",
-            "py37-django31.txt",
+            "py38-django32.txt",
         ],
         check=True,
         capture_output=True,
     )
     subprocess.run(
         [
-            "python3.7",
+            "python3.8",
             *common_args,
             "-P",
-            "Django>=3.2a1,<3.3",
+            "Django>=4.0a1,<4.1",
             "-o",
-            "py37-django32.txt",
+            "py38-django40.txt",
         ],
         check=True,
         capture_output=True,
@@ -70,9 +58,9 @@ if __name__ == "__main__":
             "python3.8",
             *common_args,
             "-P",
-            "Django>=2.2,<2.3",
+            "Django>=4.1a1,<4.2",
             "-o",
-            "py38-django22.txt",
+            "py38-django41.txt",
         ],
         check=True,
         capture_output=True,
@@ -82,45 +70,45 @@ if __name__ == "__main__":
             "python3.8",
             *common_args,
             "-P",
-            "Django>=3.0a1,<3.1",
+            "Django>=4.2a1,<5.0",
             "-o",
-            "py38-django30.txt",
+            "py38-django42.txt",
         ],
         check=True,
         capture_output=True,
     )
     subprocess.run(
         [
-            "python3.8",
+            "python3.9",
             *common_args,
             "-P",
-            "Django>=3.1a1,<3.2",
+            "Django>=3.2a1,<3.3",
             "-o",
-            "py38-django31.txt",
+            "py39-django32.txt",
         ],
         check=True,
         capture_output=True,
     )
     subprocess.run(
         [
-            "python3.8",
+            "python3.9",
             *common_args,
             "-P",
-            "Django>=3.2a1,<3.3",
+            "Django>=4.0a1,<4.1",
             "-o",
-            "py38-django32.txt",
+            "py39-django40.txt",
         ],
         check=True,
         capture_output=True,
     )
     subprocess.run(
         [
-            "python3.8",
+            "python3.9",
             *common_args,
             "-P",
-            "Django>=4.0a1,<4.1",
+            "Django>=4.1a1,<4.2",
             "-o",
-            "py38-django40.txt",
+            "py39-django41.txt",
         ],
         check=True,
         capture_output=True,
@@ -130,81 +118,81 @@ if __name__ == "__main__":
             "python3.9",
             *common_args,
             "-P",
-            "Django>=2.2,<2.3",
+            "Django>=4.2a1,<5.0",
             "-o",
-            "py39-django22.txt",
+            "py39-django42.txt",
         ],
         check=True,
         capture_output=True,
     )
     subprocess.run(
         [
-            "python3.9",
+            "python3.10",
             *common_args,
             "-P",
-            "Django>=3.0a1,<3.1",
+            "Django>=3.2a1,<3.3",
             "-o",
-            "py39-django30.txt",
+            "py310-django32.txt",
         ],
         check=True,
         capture_output=True,
     )
     subprocess.run(
         [
-            "python3.9",
+            "python3.10",
             *common_args,
             "-P",
-            "Django>=3.1a1,<3.2",
+            "Django>=4.0a1,<4.1",
             "-o",
-            "py39-django31.txt",
+            "py310-django40.txt",
         ],
         check=True,
         capture_output=True,
     )
     subprocess.run(
         [
-            "python3.9",
+            "python3.10",
             *common_args,
             "-P",
-            "Django>=3.2a1,<3.3",
+            "Django>=4.1a1,<4.2",
             "-o",
-            "py39-django32.txt",
+            "py310-django41.txt",
         ],
         check=True,
         capture_output=True,
     )
     subprocess.run(
         [
-            "python3.9",
+            "python3.10",
             *common_args,
             "-P",
-            "Django>=4.0a1,<4.1",
+            "Django>=4.2a1,<5.0",
             "-o",
-            "py39-django40.txt",
+            "py310-django42.txt",
         ],
         check=True,
         capture_output=True,
     )
     subprocess.run(
         [
-            "python3.10",
+            "python3.11",
             *common_args,
             "-P",
-            "Django>=3.2a1,<3.3",
+            "Django>=4.1a1,<4.2",
             "-o",
-            "py310-django32.txt",
+            "py311-django41.txt",
         ],
         check=True,
         capture_output=True,
     )
     subprocess.run(
         [
-            "python3.10",
+            "python3.11",
             *common_args,
             "-P",
-            "Django>=4.0a1,<4.1",
+            "Django>=4.2a1,<5.0",
             "-o",
-            "py310-django40.txt",
+            "py311-django42.txt",
         ],
         check=True,
         capture_output=True,
diff --git a/requirements/py310-django32.txt b/requirements/py310-django32.txt
index 60cf11e..c629351 100644
--- a/requirements/py310-django32.txt
+++ b/requirements/py310-django32.txt
@@ -1,48 +1,46 @@
 #
-# This file is autogenerated by pip-compile with python 3.10
-# To update, run:
+# This file is autogenerated by pip-compile with Python 3.10
+# by the following command:
 #
 #    requirements/compile.py
 #
-asgiref==3.5.0
+asgiref==3.6.0
     # via django
-attrs==21.4.0
+attrs==22.2.0
     # via pytest
 brotli==1.0.9
     # via -r requirements.in
-certifi==2021.10.8
+certifi==2022.12.7
     # via requests
-charset-normalizer==2.0.11
+charset-normalizer==3.0.1
     # via requests
-coverage==6.3
+coverage==7.1.0
     # via -r requirements.in
-django==3.2.12
+django==3.2.18
     # via -r requirements.in
-idna==3.3
+exceptiongroup==1.1.0
+    # via pytest
+idna==3.4
     # via requests
-iniconfig==1.1.1
+iniconfig==2.0.0
     # via pytest
-packaging==21.3
+packaging==23.0
     # via pytest
 pluggy==1.0.0
     # via pytest
-py==1.11.0
-    # via pytest
-pyparsing==3.0.7
-    # via packaging
-pytest==6.2.5
+pytest==7.2.1
     # via
     #   -r requirements.in
     #   pytest-randomly
-pytest-randomly==3.11.0
+pytest-randomly==3.12.0
     # via -r requirements.in
-pytz==2021.3
+pytz==2022.7.1
     # via django
-requests==2.27.1
+requests==2.28.2
     # via -r requirements.in
-sqlparse==0.4.2
+sqlparse==0.4.3
     # via django
-toml==0.10.2
+tomli==2.0.1
     # via pytest
-urllib3==1.26.8
+urllib3==1.26.14
     # via requests
diff --git a/requirements/py310-django40.txt b/requirements/py310-django40.txt
index ad0c4c8..124b1d4 100644
--- a/requirements/py310-django40.txt
+++ b/requirements/py310-django40.txt
@@ -1,46 +1,44 @@
 #
-# This file is autogenerated by pip-compile with python 3.10
-# To update, run:
+# This file is autogenerated by pip-compile with Python 3.10
+# by the following command:
 #
 #    requirements/compile.py
 #
-asgiref==3.5.0
+asgiref==3.6.0
     # via django
-attrs==21.4.0
+attrs==22.2.0
     # via pytest
 brotli==1.0.9
     # via -r requirements.in
-certifi==2021.10.8
+certifi==2022.12.7
     # via requests
-charset-normalizer==2.0.11
+charset-normalizer==3.0.1
     # via requests
-coverage==6.3
+coverage==7.1.0
     # via -r requirements.in
-django==4.0.2
+django==4.0.10
     # via -r requirements.in
-idna==3.3
+exceptiongroup==1.1.0
+    # via pytest
+idna==3.4
     # via requests
-iniconfig==1.1.1
+iniconfig==2.0.0
     # via pytest
-packaging==21.3
+packaging==23.0
     # via pytest
 pluggy==1.0.0
     # via pytest
-py==1.11.0
-    # via pytest
-pyparsing==3.0.7
-    # via packaging
-pytest==6.2.5
+pytest==7.2.1
     # via
     #   -r requirements.in
     #   pytest-randomly
-pytest-randomly==3.11.0
+pytest-randomly==3.12.0
     # via -r requirements.in
-requests==2.27.1
+requests==2.28.2
     # via -r requirements.in
-sqlparse==0.4.2
+sqlparse==0.4.3
     # via django
-toml==0.10.2
+tomli==2.0.1
     # via pytest
-urllib3==1.26.8
+urllib3==1.26.14
     # via requests
diff --git a/requirements/py310-django41.txt b/requirements/py310-django41.txt
new file mode 100644
index 0000000..38349b5
--- /dev/null
+++ b/requirements/py310-django41.txt
@@ -0,0 +1,44 @@
+#
+# This file is autogenerated by pip-compile with Python 3.10
+# by the following command:
+#
+#    requirements/compile.py
+#
+asgiref==3.6.0
+    # via django
+attrs==22.2.0
+    # via pytest
+brotli==1.0.9
+    # via -r requirements.in
+certifi==2022.12.7
+    # via requests
+charset-normalizer==3.0.1
+    # via requests
+coverage==7.1.0
+    # via -r requirements.in
+django==4.1.7
+    # via -r requirements.in
+exceptiongroup==1.1.0
+    # via pytest
+idna==3.4
+    # via requests
+iniconfig==2.0.0
+    # via pytest
+packaging==23.0
+    # via pytest
+pluggy==1.0.0
+    # via pytest
+pytest==7.2.1
+    # via
+    #   -r requirements.in
+    #   pytest-randomly
+pytest-randomly==3.12.0
+    # via -r requirements.in
+requests==2.28.2
+    # via -r requirements.in
+sqlparse==0.4.3
+    # via django
+tomli==2.0.1
+    # via pytest
+urllib3==1.26.14
+    # via requests
diff --git a/requirements/py310-django42.txt b/requirements/py310-django42.txt
new file mode 100644
index 0000000..a1c89a4
--- /dev/null
+++ b/requirements/py310-django42.txt
@@ -0,0 +1,44 @@
+#
+# This file is autogenerated by pip-compile with Python 3.10
+# by the following command:
+#
+#    requirements/compile.py
+#
+asgiref==3.6.0
+    # via django
+attrs==22.2.0
+    # via pytest
+brotli==1.0.9
+    # via -r requirements.in
+certifi==2022.12.7
+    # via requests
+charset-normalizer==3.0.1
+    # via requests
+coverage==7.1.0
+    # via -r requirements.in
+django==4.2b1
+    # via -r requirements.in
+exceptiongroup==1.1.0
+    # via pytest
+idna==3.4
+    # via requests
+iniconfig==2.0.0
+    # via pytest
+packaging==23.0
+    # via pytest
+pluggy==1.0.0
+    # via pytest
+pytest==7.2.1
+    # via
+    #   -r requirements.in
+    #   pytest-randomly
+pytest-randomly==3.12.0
+    # via -r requirements.in
+requests==2.28.2
+    # via -r requirements.in
+sqlparse==0.4.3
+    # via django
+tomli==2.0.1
+    # via pytest
+urllib3==1.26.14
+    # via requests
diff --git a/requirements/py311-django41.txt b/requirements/py311-django41.txt
new file mode 100644
index 0000000..89ddfe0
--- /dev/null
+++ b/requirements/py311-django41.txt
@@ -0,0 +1,40 @@
+#
+# This file is autogenerated by pip-compile with Python 3.11
+# by the following command:
+#
+#    requirements/compile.py
+#
+asgiref==3.6.0
+    # via django
+attrs==22.2.0
+    # via pytest
+brotli==1.0.9
+    # via -r requirements.in
+certifi==2022.12.7
+    # via requests
+charset-normalizer==3.0.1
+    # via requests
+coverage==7.1.0
+    # via -r requirements.in
+django==4.1.7
+    # via -r requirements.in
+idna==3.4
+    # via requests
+iniconfig==2.0.0
+    # via pytest
+packaging==23.0
+    # via pytest
+pluggy==1.0.0
+    # via pytest
+pytest==7.2.1
+    # via
+    #   -r requirements.in
+    #   pytest-randomly
+pytest-randomly==3.12.0
+    # via -r requirements.in
+requests==2.28.2
+    # via -r requirements.in
+sqlparse==0.4.3
+    # via django
+urllib3==1.26.14
+    # via requests
diff --git a/requirements/py311-django42.txt b/requirements/py311-django42.txt
new file mode 100644
index 0000000..773991c
--- /dev/null
+++ b/requirements/py311-django42.txt
@@ -0,0 +1,40 @@
+#
+# This file is autogenerated by pip-compile with Python 3.11
+# by the following command:
+#
+#    requirements/compile.py
+#
+asgiref==3.6.0
+    # via django
+attrs==22.2.0
+    # via pytest
+brotli==1.0.9
+    # via -r requirements.in
+certifi==2022.12.7
+    # via requests
+charset-normalizer==3.0.1
+    # via requests
+coverage==7.1.0
+    # via -r requirements.in
+django==4.2b1
+    # via -r requirements.in
+idna==3.4
+    # via requests
+iniconfig==2.0.0
+    # via pytest
+packaging==23.0
+    # via pytest
+pluggy==1.0.0
+    # via pytest
+pytest==7.2.1
+    # via
+    #   -r requirements.in
+    #   pytest-randomly
+pytest-randomly==3.12.0
+    # via -r requirements.in
+requests==2.28.2
+    # via -r requirements.in
+sqlparse==0.4.3
+    # via django
+urllib3==1.26.14
+    # via requests
diff --git a/requirements/py37-django22.txt b/requirements/py37-django22.txt
deleted file mode 100644
index cc6183d..0000000
--- a/requirements/py37-django22.txt
+++ /dev/null
@@ -1,55 +0,0 @@
-#
-# This file is autogenerated by pip-compile with python 3.7
-# To update, run:
-#
-#    requirements/compile.py
-#
-attrs==21.4.0
-    # via pytest
-brotli==1.0.9
-    # via -r requirements.in
-certifi==2021.10.8
-    # via requests
-charset-normalizer==2.0.11
-    # via requests
-coverage==6.3
-    # via -r requirements.in
-django==2.2.27
-    # via -r requirements.in
-idna==3.3
-    # via requests
-importlib-metadata==4.10.1
-    # via
-    #   pluggy
-    #   pytest
-    #   pytest-randomly
-iniconfig==1.1.1
-    # via pytest
-packaging==21.3
-    # via pytest
-pluggy==1.0.0
-    # via pytest
-py==1.11.0
-    # via pytest
-pyparsing==3.0.7
-    # via packaging
-pytest==6.2.5
-    # via
-    #   -r requirements.in
-    #   pytest-randomly
-pytest-randomly==3.11.0
-    # via -r requirements.in
-pytz==2021.3
-    # via django
-requests==2.27.1
-    # via -r requirements.in
-sqlparse==0.4.2
-    # via django
-toml==0.10.2
-    # via pytest
-typing-extensions==4.0.1
-    # via importlib-metadata
-urllib3==1.26.8
-    # via requests
-zipp==3.7.0
-    # via importlib-metadata
diff --git a/requirements/py37-django30.txt b/requirements/py37-django30.txt
deleted file mode 100644
index a5e5c5c..0000000
--- a/requirements/py37-django30.txt
+++ /dev/null
@@ -1,59 +0,0 @@
-#
-# This file is autogenerated by pip-compile with python 3.7
-# To update, run:
-#
-#    requirements/compile.py
-#
-asgiref==3.5.0
-    # via django
-attrs==21.4.0
-    # via pytest
-brotli==1.0.9
-    # via -r requirements.in
-certifi==2021.10.8
-    # via requests
-charset-normalizer==2.0.11
-    # via requests
-coverage==6.3
-    # via -r requirements.in
-django==3.0.14
-    # via -r requirements.in
-idna==3.3
-    # via requests
-importlib-metadata==4.10.1
-    # via
-    #   pluggy
-    #   pytest
-    #   pytest-randomly
-iniconfig==1.1.1
-    # via pytest
-packaging==21.3
-    # via pytest
-pluggy==1.0.0
-    # via pytest
-py==1.11.0
-    # via pytest
-pyparsing==3.0.7
-    # via packaging
-pytest==6.2.5
-    # via
-    #   -r requirements.in
-    #   pytest-randomly
-pytest-randomly==3.11.0
-    # via -r requirements.in
-pytz==2021.3
-    # via django
-requests==2.27.1
-    # via -r requirements.in
-sqlparse==0.4.2
-    # via django
-toml==0.10.2
-    # via pytest
-typing-extensions==4.0.1
-    # via
-    #   asgiref
-    #   importlib-metadata
-urllib3==1.26.8
-    # via requests
-zipp==3.7.0
-    # via importlib-metadata
diff --git a/requirements/py37-django31.txt b/requirements/py37-django31.txt
deleted file mode 100644
index d1a26b1..0000000
--- a/requirements/py37-django31.txt
+++ /dev/null
@@ -1,59 +0,0 @@
-#
-# This file is autogenerated by pip-compile with python 3.7
-# To update, run:
-#
-#    requirements/compile.py
-#
-asgiref==3.5.0
-    # via django
-attrs==21.4.0
-    # via pytest
-brotli==1.0.9
-    # via -r requirements.in
-certifi==2021.10.8
-    # via requests
-charset-normalizer==2.0.11
-    # via requests
-coverage==6.3
-    # via -r requirements.in
-django==3.1.14
-    # via -r requirements.in
-idna==3.3
-    # via requests
-importlib-metadata==4.10.1
-    # via
-    #   pluggy
-    #   pytest
-    #   pytest-randomly
-iniconfig==1.1.1
-    # via pytest
-packaging==21.3
-    # via pytest
-pluggy==1.0.0
-    # via pytest
-py==1.11.0
-    # via pytest
-pyparsing==3.0.7
-    # via packaging
-pytest==6.2.5
-    # via
-    #   -r requirements.in
-    #   pytest-randomly
-pytest-randomly==3.11.0
-    # via -r requirements.in
-pytz==2021.3
-    # via django
-requests==2.27.1
-    # via -r requirements.in
-sqlparse==0.4.2
-    # via django
-toml==0.10.2
-    # via pytest
-typing-extensions==4.0.1
-    # via
-    #   asgiref
-    #   importlib-metadata
-urllib3==1.26.8
-    # via requests
-zipp==3.7.0
-    # via importlib-metadata
diff --git a/requirements/py37-django32.txt b/requirements/py37-django32.txt
index b698d91..621b875 100644
--- a/requirements/py37-django32.txt
+++ b/requirements/py37-django32.txt
@@ -1,59 +1,57 @@
 #
-# This file is autogenerated by pip-compile with python 3.7
-# To update, run:
+# This file is autogenerated by pip-compile with Python 3.7
+# by the following command:
 #
 #    requirements/compile.py
 #
-asgiref==3.5.0
+asgiref==3.6.0
     # via django
-attrs==21.4.0
+attrs==22.2.0
     # via pytest
 brotli==1.0.9
     # via -r requirements.in
-certifi==2021.10.8
+certifi==2022.12.7
     # via requests
-charset-normalizer==2.0.11
+charset-normalizer==3.0.1
     # via requests
-coverage==6.3
+coverage==7.1.0
     # via -r requirements.in
-django==3.2.12
+django==3.2.18
     # via -r requirements.in
-idna==3.3
+exceptiongroup==1.1.0
+    # via pytest
+idna==3.4
     # via requests
-importlib-metadata==4.10.1
+importlib-metadata==6.0.0
     # via
     #   pluggy
     #   pytest
     #   pytest-randomly
-iniconfig==1.1.1
+iniconfig==2.0.0
     # via pytest
-packaging==21.3
+packaging==23.0
     # via pytest
 pluggy==1.0.0
     # via pytest
-py==1.11.0
-    # via pytest
-pyparsing==3.0.7
-    # via packaging
-pytest==6.2.5
+pytest==7.2.1
     # via
     #   -r requirements.in
     #   pytest-randomly
-pytest-randomly==3.11.0
+pytest-randomly==3.12.0
     # via -r requirements.in
-pytz==2021.3
+pytz==2022.7.1
     # via django
-requests==2.27.1
+requests==2.28.2
     # via -r requirements.in
-sqlparse==0.4.2
+sqlparse==0.4.3
     # via django
-toml==0.10.2
+tomli==2.0.1
     # via pytest
-typing-extensions==4.0.1
+typing-extensions==4.5.0
     # via
     #   asgiref
     #   importlib-metadata
-urllib3==1.26.8
+urllib3==1.26.14
     # via requests
-zipp==3.7.0
+zipp==3.14.0
     # via importlib-metadata
diff --git a/requirements/py38-django31.txt b/requirements/py38-django31.txt
deleted file mode 100644
index 6e37c55..0000000
--- a/requirements/py38-django31.txt
+++ /dev/null
@@ -1,52 +0,0 @@
-#
-# This file is autogenerated by pip-compile with python 3.8
-# To update, run:
-#
-#    requirements/compile.py
-#
-asgiref==3.5.0
-    # via django
-attrs==21.4.0
-    # via pytest
-brotli==1.0.9
-    # via -r requirements.in
-certifi==2021.10.8
-    # via requests
-charset-normalizer==2.0.11
-    # via requests
-coverage==6.3
-    # via -r requirements.in
-django==3.1.14
-    # via -r requirements.in
-idna==3.3
-    # via requests
-importlib-metadata==4.10.1
-    # via pytest-randomly
-iniconfig==1.1.1
-    # via pytest
-packaging==21.3
-    # via pytest
-pluggy==1.0.0
-    # via pytest
-py==1.11.0
-    # via pytest
-pyparsing==3.0.7
-    # via packaging
-pytest==6.2.5
-    # via
-    #   -r requirements.in
-    #   pytest-randomly
-pytest-randomly==3.11.0
-    # via -r requirements.in
-pytz==2021.3
-    # via django
-requests==2.27.1
-    # via -r requirements.in
-sqlparse==0.4.2
-    # via django
-toml==0.10.2
-    # via pytest
-urllib3==1.26.8
-    # via requests
-zipp==3.7.0
-    # via importlib-metadata
diff --git a/requirements/py38-django32.txt b/requirements/py38-django32.txt
index 1fd72be..1097620 100644
--- a/requirements/py38-django32.txt
+++ b/requirements/py38-django32.txt
@@ -1,52 +1,50 @@
 #
-# This file is autogenerated by pip-compile with python 3.8
-# To update, run:
+# This file is autogenerated by pip-compile with Python 3.8
+# by the following command:
 #
 #    requirements/compile.py
 #
-asgiref==3.5.0
+asgiref==3.6.0
     # via django
-attrs==21.4.0
+attrs==22.2.0
     # via pytest
 brotli==1.0.9
     # via -r requirements.in
-certifi==2021.10.8
+certifi==2022.12.7
     # via requests
-charset-normalizer==2.0.11
+charset-normalizer==3.0.1
     # via requests
-coverage==6.3
+coverage==7.1.0
     # via -r requirements.in
-django==3.2.12
+django==3.2.18
     # via -r requirements.in
-idna==3.3
+exceptiongroup==1.1.0
+    # via pytest
+idna==3.4
     # via requests
-importlib-metadata==4.10.1
+importlib-metadata==6.0.0
     # via pytest-randomly
-iniconfig==1.1.1
+iniconfig==2.0.0
     # via pytest
-packaging==21.3
+packaging==23.0
     # via pytest
 pluggy==1.0.0
     # via pytest
-py==1.11.0
-    # via pytest
-pyparsing==3.0.7
-    # via packaging
-pytest==6.2.5
+pytest==7.2.1
     # via
     #   -r requirements.in
     #   pytest-randomly
-pytest-randomly==3.11.0
+pytest-randomly==3.12.0
     # via -r requirements.in
-pytz==2021.3
+pytz==2022.7.1
     # via django
-requests==2.27.1
+requests==2.28.2
     # via -r requirements.in
-sqlparse==0.4.2
+sqlparse==0.4.3
     # via django
-toml==0.10.2
+tomli==2.0.1
     # via pytest
-urllib3==1.26.8
+urllib3==1.26.14
     # via requests
-zipp==3.7.0
+zipp==3.14.0
     # via importlib-metadata
diff --git a/requirements/py38-django40.txt b/requirements/py38-django40.txt
index ff76320..060c87d 100644
--- a/requirements/py38-django40.txt
+++ b/requirements/py38-django40.txt
@@ -1,52 +1,50 @@
 #
-# This file is autogenerated by pip-compile with python 3.8
-# To update, run:
+# This file is autogenerated by pip-compile with Python 3.8
+# by the following command:
 #
 #    requirements/compile.py
 #
-asgiref==3.5.0
+asgiref==3.6.0
     # via django
-attrs==21.4.0
+attrs==22.2.0
     # via pytest
-backports.zoneinfo==0.2.1
+backports-zoneinfo==0.2.1
     # via django
 brotli==1.0.9
     # via -r requirements.in
-certifi==2021.10.8
+certifi==2022.12.7
     # via requests
-charset-normalizer==2.0.11
+charset-normalizer==3.0.1
     # via requests
-coverage==6.3
+coverage==7.1.0
     # via -r requirements.in
-django==4.0.2
+django==4.0.10
     # via -r requirements.in
-idna==3.3
+exceptiongroup==1.1.0
+    # via pytest
+idna==3.4
     # via requests
-importlib-metadata==4.10.1
+importlib-metadata==6.0.0
     # via pytest-randomly
-iniconfig==1.1.1
+iniconfig==2.0.0
     # via pytest
-packaging==21.3
+packaging==23.0
     # via pytest
 pluggy==1.0.0
     # via pytest
-py==1.11.0
-    # via pytest
-pyparsing==3.0.7
-    # via packaging
-pytest==6.2.5
+pytest==7.2.1
     # via
     #   -r requirements.in
     #   pytest-randomly
-pytest-randomly==3.11.0
+pytest-randomly==3.12.0
     # via -r requirements.in
-requests==2.27.1
+requests==2.28.2
     # via -r requirements.in
-sqlparse==0.4.2
+sqlparse==0.4.3
     # via django
-toml==0.10.2
+tomli==2.0.1
     # via pytest
-urllib3==1.26.8
+urllib3==1.26.14
     # via requests
-zipp==3.7.0
+zipp==3.14.0
     # via importlib-metadata
diff --git a/requirements/py39-django30.txt b/requirements/py38-django41.txt
similarity index 56%
rename from requirements/py39-django30.txt
rename to requirements/py38-django41.txt
index 88838c6..5ecc293 100644
--- a/requirements/py39-django30.txt
+++ b/requirements/py38-django41.txt
@@ -1,52 +1,50 @@
 #
-# This file is autogenerated by pip-compile with python 3.9
-# To update, run:
+# This file is autogenerated by pip-compile with Python 3.8
+# by the following command:
 #
 #    requirements/compile.py
 #
-asgiref==3.5.0
+asgiref==3.6.0
     # via django
-attrs==21.4.0
+attrs==22.2.0
     # via pytest
+backports-zoneinfo==0.2.1
+    # via django
 brotli==1.0.9
     # via -r requirements.in
-certifi==2021.10.8
+certifi==2022.12.7
     # via requests
-charset-normalizer==2.0.11
+charset-normalizer==3.0.1
     # via requests
-coverage==6.3
+coverage==7.1.0
     # via -r requirements.in
-django==3.0.14
+django==4.1.7
     # via -r requirements.in
-idna==3.3
+exceptiongroup==1.1.0
+    # via pytest
+idna==3.4
     # via requests
-importlib-metadata==4.10.1
+importlib-metadata==6.0.0
     # via pytest-randomly
-iniconfig==1.1.1
+iniconfig==2.0.0
     # via pytest
-packaging==21.3
+packaging==23.0
     # via pytest
 pluggy==1.0.0
     # via pytest
-py==1.11.0
-    # via pytest
-pyparsing==3.0.7
-    # via packaging
-pytest==6.2.5
+pytest==7.2.1
     # via
     #   -r requirements.in
     #   pytest-randomly
-pytest-randomly==3.11.0
+pytest-randomly==3.12.0
     # via -r requirements.in
-pytz==2021.3
-    # via django
-requests==2.27.1
+requests==2.28.2
     # via -r requirements.in
-sqlparse==0.4.2
+sqlparse==0.4.3
     # via django
-toml==0.10.2
+tomli==2.0.1
     # via pytest
-urllib3==1.26.8
+urllib3==1.26.14
     # via requests
-zipp==3.7.0
+zipp==3.14.0
     # via importlib-metadata
diff --git a/requirements/py38-django30.txt b/requirements/py38-django42.txt
similarity index 56%
rename from requirements/py38-django30.txt
rename to requirements/py38-django42.txt
index fbe06e3..ea4b26b 100644
--- a/requirements/py38-django30.txt
+++ b/requirements/py38-django42.txt
@@ -1,52 +1,50 @@
 #
-# This file is autogenerated by pip-compile with python 3.8
-# To update, run:
+# This file is autogenerated by pip-compile with Python 3.8
+# by the following command:
 #
 #    requirements/compile.py
 #
-asgiref==3.5.0
+asgiref==3.6.0
     # via django
-attrs==21.4.0
+attrs==22.2.0
     # via pytest
+backports-zoneinfo==0.2.1
+    # via django
 brotli==1.0.9
     # via -r requirements.in
-certifi==2021.10.8
+certifi==2022.12.7
     # via requests
-charset-normalizer==2.0.11
+charset-normalizer==3.0.1
     # via requests
-coverage==6.3
+coverage==7.1.0
     # via -r requirements.in
-django==3.0.14
+django==4.2b1
     # via -r requirements.in
-idna==3.3
+exceptiongroup==1.1.0
+    # via pytest
+idna==3.4
     # via requests
-importlib-metadata==4.10.1
+importlib-metadata==6.0.0
     # via pytest-randomly
-iniconfig==1.1.1
+iniconfig==2.0.0
     # via pytest
-packaging==21.3
+packaging==23.0
     # via pytest
 pluggy==1.0.0
     # via pytest
-py==1.11.0
-    # via pytest
-pyparsing==3.0.7
-    # via packaging
-pytest==6.2.5
+pytest==7.2.1
     # via
     #   -r requirements.in
     #   pytest-randomly
-pytest-randomly==3.11.0
+pytest-randomly==3.12.0
     # via -r requirements.in
-pytz==2021.3
-    # via django
-requests==2.27.1
+requests==2.28.2
     # via -r requirements.in
-sqlparse==0.4.2
+sqlparse==0.4.3
     # via django
-toml==0.10.2
+tomli==2.0.1
     # via pytest
-urllib3==1.26.8
+urllib3==1.26.14
     # via requests
-zipp==3.7.0
+zipp==3.14.0
     # via importlib-metadata
diff --git a/requirements/py39-django31.txt b/requirements/py39-django31.txt
deleted file mode 100644
index f1fc3c4..0000000
--- a/requirements/py39-django31.txt
+++ /dev/null
@@ -1,52 +0,0 @@
-#
-# This file is autogenerated by pip-compile with python 3.9
-# To update, run:
-#
-#    requirements/compile.py
-#
-asgiref==3.5.0
-    # via django
-attrs==21.4.0
-    # via pytest
-brotli==1.0.9
-    # via -r requirements.in
-certifi==2021.10.8
-    # via requests
-charset-normalizer==2.0.11
-    # via requests
-coverage==6.3
-    # via -r requirements.in
-django==3.1.14
-    # via -r requirements.in
-idna==3.3
-    # via requests
-importlib-metadata==4.10.1
-    # via pytest-randomly
-iniconfig==1.1.1
-    # via pytest
-packaging==21.3
-    # via pytest
-pluggy==1.0.0
-    # via pytest
-py==1.11.0
-    # via pytest
-pyparsing==3.0.7
-    # via packaging
-pytest==6.2.5
-    # via
-    #   -r requirements.in
-    #   pytest-randomly
-pytest-randomly==3.11.0
-    # via -r requirements.in
-pytz==2021.3
-    # via django
-requests==2.27.1
-    # via -r requirements.in
-sqlparse==0.4.2
-    # via django
-toml==0.10.2
-    # via pytest
-urllib3==1.26.8
-    # via requests
-zipp==3.7.0
-    # via importlib-metadata
diff --git a/requirements/py39-django32.txt b/requirements/py39-django32.txt
index 7924a3e..31406fc 100644
--- a/requirements/py39-django32.txt
+++ b/requirements/py39-django32.txt
@@ -1,52 +1,50 @@
 #
-# This file is autogenerated by pip-compile with python 3.9
-# To update, run:
+# This file is autogenerated by pip-compile with Python 3.9
+# by the following command:
 #
 #    requirements/compile.py
 #
-asgiref==3.5.0
+asgiref==3.6.0
     # via django
-attrs==21.4.0
+attrs==22.2.0
     # via pytest
 brotli==1.0.9
     # via -r requirements.in
-certifi==2021.10.8
+certifi==2022.12.7
     # via requests
-charset-normalizer==2.0.11
+charset-normalizer==3.0.1
     # via requests
-coverage==6.3
+coverage==7.1.0
     # via -r requirements.in
-django==3.2.12
+django==3.2.18
     # via -r requirements.in
-idna==3.3
+exceptiongroup==1.1.0
+    # via pytest
+idna==3.4
     # via requests
-importlib-metadata==4.10.1
+importlib-metadata==6.0.0
     # via pytest-randomly
-iniconfig==1.1.1
+iniconfig==2.0.0
     # via pytest
-packaging==21.3
+packaging==23.0
     # via pytest
 pluggy==1.0.0
     # via pytest
-py==1.11.0
-    # via pytest
-pyparsing==3.0.7
-    # via packaging
-pytest==6.2.5
+pytest==7.2.1
     # via
     #   -r requirements.in
     #   pytest-randomly
-pytest-randomly==3.11.0
+pytest-randomly==3.12.0
     # via -r requirements.in
-pytz==2021.3
+pytz==2022.7.1
     # via django
-requests==2.27.1
+requests==2.28.2
     # via -r requirements.in
-sqlparse==0.4.2
+sqlparse==0.4.3
     # via django
-toml==0.10.2
+tomli==2.0.1
     # via pytest
-urllib3==1.26.8
+urllib3==1.26.14
     # via requests
-zipp==3.7.0
+zipp==3.14.0
     # via importlib-metadata
diff --git a/requirements/py39-django40.txt b/requirements/py39-django40.txt
index 43f276d..147abd9 100644
--- a/requirements/py39-django40.txt
+++ b/requirements/py39-django40.txt
@@ -1,50 +1,48 @@
 #
-# This file is autogenerated by pip-compile with python 3.9
-# To update, run:
+# This file is autogenerated by pip-compile with Python 3.9
+# by the following command:
 #
 #    requirements/compile.py
 #
-asgiref==3.5.0
+asgiref==3.6.0
     # via django
-attrs==21.4.0
+attrs==22.2.0
     # via pytest
 brotli==1.0.9
     # via -r requirements.in
-certifi==2021.10.8
+certifi==2022.12.7
     # via requests
-charset-normalizer==2.0.11
+charset-normalizer==3.0.1
     # via requests
-coverage==6.3
+coverage==7.1.0
     # via -r requirements.in
-django==4.0.2
+django==4.0.10
     # via -r requirements.in
-idna==3.3
+exceptiongroup==1.1.0
+    # via pytest
+idna==3.4
     # via requests
-importlib-metadata==4.10.1
+importlib-metadata==6.0.0
     # via pytest-randomly
-iniconfig==1.1.1
+iniconfig==2.0.0
     # via pytest
-packaging==21.3
+packaging==23.0
     # via pytest
 pluggy==1.0.0
     # via pytest
-py==1.11.0
-    # via pytest
-pyparsing==3.0.7
-    # via packaging
-pytest==6.2.5
+pytest==7.2.1
     # via
     #   -r requirements.in
     #   pytest-randomly
-pytest-randomly==3.11.0
+pytest-randomly==3.12.0
     # via -r requirements.in
-requests==2.27.1
+requests==2.28.2
     # via -r requirements.in
-sqlparse==0.4.2
+sqlparse==0.4.3
     # via django
-toml==0.10.2
+tomli==2.0.1
     # via pytest
-urllib3==1.26.8
+urllib3==1.26.14
     # via requests
-zipp==3.7.0
+zipp==3.14.0
     # via importlib-metadata
diff --git a/requirements/py39-django22.txt b/requirements/py39-django41.txt
similarity index 56%
rename from requirements/py39-django22.txt
rename to requirements/py39-django41.txt
index 1a6c57a..de60b3d 100644
--- a/requirements/py39-django22.txt
+++ b/requirements/py39-django41.txt
@@ -1,50 +1,48 @@
 #
-# This file is autogenerated by pip-compile with python 3.9
-# To update, run:
+# This file is autogenerated by pip-compile with Python 3.9
+# by the following command:
 #
 #    requirements/compile.py
 #
-attrs==21.4.0
+asgiref==3.6.0
+    # via django
+attrs==22.2.0
     # via pytest
 brotli==1.0.9
     # via -r requirements.in
-certifi==2021.10.8
+certifi==2022.12.7
     # via requests
-charset-normalizer==2.0.11
+charset-normalizer==3.0.1
     # via requests
-coverage==6.3
+coverage==7.1.0
     # via -r requirements.in
-django==2.2.27
+django==4.1.7
     # via -r requirements.in
-idna==3.3
+exceptiongroup==1.1.0
+    # via pytest
+idna==3.4
     # via requests
-importlib-metadata==4.10.1
+importlib-metadata==6.0.0
     # via pytest-randomly
-iniconfig==1.1.1
+iniconfig==2.0.0
     # via pytest
-packaging==21.3
+packaging==23.0
     # via pytest
 pluggy==1.0.0
     # via pytest
-py==1.11.0
-    # via pytest
-pyparsing==3.0.7
-    # via packaging
-pytest==6.2.5
+pytest==7.2.1
     # via
     #   -r requirements.in
     #   pytest-randomly
-pytest-randomly==3.11.0
+pytest-randomly==3.12.0
     # via -r requirements.in
-pytz==2021.3
-    # via django
-requests==2.27.1
+requests==2.28.2
     # via -r requirements.in
-sqlparse==0.4.2
+sqlparse==0.4.3
     # via django
-toml==0.10.2
+tomli==2.0.1
     # via pytest
-urllib3==1.26.8
+urllib3==1.26.14
     # via requests
-zipp==3.7.0
+zipp==3.14.0
     # via importlib-metadata
diff --git a/requirements/py38-django22.txt b/requirements/py39-django42.txt
similarity index 56%
rename from requirements/py38-django22.txt
rename to requirements/py39-django42.txt
index f2301b0..eff809f 100644
--- a/requirements/py38-django22.txt
+++ b/requirements/py39-django42.txt
@@ -1,50 +1,48 @@
 #
-# This file is autogenerated by pip-compile with python 3.8
-# To update, run:
+# This file is autogenerated by pip-compile with Python 3.9
+# by the following command:
 #
 #    requirements/compile.py
 #
-attrs==21.4.0
+asgiref==3.6.0
+    # via django
+attrs==22.2.0
     # via pytest
 brotli==1.0.9
     # via -r requirements.in
-certifi==2021.10.8
+certifi==2022.12.7
     # via requests
-charset-normalizer==2.0.11
+charset-normalizer==3.0.1
     # via requests
-coverage==6.3
+coverage==7.1.0
     # via -r requirements.in
-django==2.2.27
+django==4.2b1
     # via -r requirements.in
-idna==3.3
+exceptiongroup==1.1.0
+    # via pytest
+idna==3.4
     # via requests
-importlib-metadata==4.10.1
+importlib-metadata==6.0.0
     # via pytest-randomly
-iniconfig==1.1.1
+iniconfig==2.0.0
     # via pytest
-packaging==21.3
+packaging==23.0
     # via pytest
 pluggy==1.0.0
     # via pytest
-py==1.11.0
-    # via pytest
-pyparsing==3.0.7
-    # via packaging
-pytest==6.2.5
+pytest==7.2.1
     # via
     #   -r requirements.in
     #   pytest-randomly
-pytest-randomly==3.11.0
+pytest-randomly==3.12.0
     # via -r requirements.in
-pytz==2021.3
-    # via django
-requests==2.27.1
+requests==2.28.2
     # via -r requirements.in
-sqlparse==0.4.2
+sqlparse==0.4.3
     # via django
-toml==0.10.2
+tomli==2.0.1
     # via pytest
-urllib3==1.26.8
+urllib3==1.26.14
     # via requests
-zipp==3.7.0
+zipp==3.14.0
     # via importlib-metadata
diff --git a/scripts/generate_default_media_types.py b/scripts/generate_default_media_types.py
index f8e0b86..0d69174 100755
--- a/scripts/generate_default_media_types.py
+++ b/scripts/generate_default_media_types.py
@@ -11,7 +11,7 @@ module_dir = Path(__file__).parent.resolve()
 media_types_py = module_dir / "../src/whitenoise/media_types.py"
 
 
-def main():
+def main() -> int:
     parser = argparse.ArgumentParser()
     parser.add_argument("--check", action="store_true")
     args = parser.parse_args()
@@ -50,7 +50,7 @@ EXTRA_MIMETYPES = {
 
 
 FUNCTION_TEMPLATE = '''\
-def default_types():
+def default_types() -> dict[str, str]:
     """
     We use our own set of default media types rather than the system-supplied
     ones. This ensures consistent media type behaviour across varied
@@ -64,16 +64,16 @@ def default_types():
     }}'''
 
 
-def get_default_types_function():
+def get_default_types_function() -> str:
     types_map = get_types_map()
     lines = [
-        f'        "{suffix}": "{media_type}",'
+        f'        "{suffix}": "{media_type}",'  # noqa: B028
         for suffix, media_type in types_map.items()
     ]
     return FUNCTION_TEMPLATE.format(entries="\n".join(lines))
 
 
-def get_types_map():
+def get_types_map() -> dict[str, str]:
     nginx_data = get_nginx_data()
     matches = re.findall(r"(\w+/.*?)\s+(.*?);", nginx_data)
     types_map = {}
@@ -91,7 +91,7 @@ def get_types_map():
     return dict(sorted(types_map.items()))
 
 
-def get_nginx_data():
+def get_nginx_data() -> str:
     conn = http.client.HTTPSConnection("raw.githubusercontent.com")
     with closing(conn):
         conn.request("GET", "/nginx/nginx/master/conf/mime.types")
diff --git a/setup.cfg b/setup.cfg
index 6ff8fbd..fe288bb 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,52 +1,54 @@
 [metadata]
 name = whitenoise
-version = 6.0.0
+version = 6.4.0
 description = Radically simplified static file serving for WSGI applications
 long_description = file: README.rst
 long_description_content_type = text/x-rst
+url = https://github.com/evansd/whitenoise
 author = David Evans
 author_email = d@evans.io
-url = https://whitenoise.evans.io
-project_urls =
-    Documentation = https://whitenoise.evans.io/
-    Changelog = https://whitenoise.evans.io/en/stable/changelog.html
 license = MIT
-keywords = Django
+license_file = LICENSE
 classifiers =
     Development Status :: 5 - Production/Stable
     Framework :: Django
-    Framework :: Django :: 2.2
-    Framework :: Django :: 3.0
-    Framework :: Django :: 3.1
     Framework :: Django :: 3.2
     Framework :: Django :: 4.0
+    Framework :: Django :: 4.1
+    Framework :: Django :: 4.2
     Intended Audience :: Developers
     License :: OSI Approved :: MIT License
     Operating System :: OS Independent
-    Programming Language :: Python :: 3 :: Only
     Programming Language :: Python :: 3
+    Programming Language :: Python :: 3 :: Only
     Programming Language :: Python :: 3.7
     Programming Language :: Python :: 3.8
     Programming Language :: Python :: 3.9
     Programming Language :: Python :: 3.10
+    Programming Language :: Python :: 3.11
+    Programming Language :: Python :: Implementation :: CPython
     Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware
-license_file = LICENSE
+    Typing :: Typed
+keywords = Django
+project_urls =
+    Documentation = https://whitenoise.evans.io/
+    Changelog = https://whitenoise.evans.io/en/stable/changelog.html
 
 [options]
-package_dir=
-    =src
 packages = find:
-include_package_data = True
 python_requires = >=3.7
+include_package_data = True
+package_dir =
+    =src
 zip_safe = False
 
+[options.packages.find]
+where = src
+
 [options.extras_require]
 brotli =
     Brotli
 
-[options.packages.find]
-where = src
-
 [flake8]
 max-line-length = 88
 extend-ignore = E203
@@ -62,8 +64,8 @@ source =
 
 [coverage:paths]
 source =
-   src
-   .tox/*/site-packages
+    src
+    .tox/**/site-packages
 
 [coverage:report]
 show_missing = True
diff --git a/setup.py b/setup.py
deleted file mode 100644
index a03590f..0000000
--- a/setup.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from __future__ import annotations
-
-from setuptools import setup
-
-setup()
diff --git a/src/whitenoise/base.py b/src/whitenoise/base.py
index e7bcf2a..45c58f9 100644
--- a/src/whitenoise/base.py
+++ b/src/whitenoise/base.py
@@ -4,74 +4,70 @@ import os
 import re
 import warnings
 from posixpath import normpath
+from typing import Callable
 from wsgiref.headers import Headers
 from wsgiref.util import FileWrapper
 
 from .media_types import MediaTypes
-from .responders import IsDirectoryError, MissingFileError, Redirect, StaticFile
-from .string_utils import (
-    decode_if_byte_string,
-    decode_path_info,
-    ensure_leading_trailing_slash,
-)
+from .responders import IsDirectoryError
+from .responders import MissingFileError
+from .responders import Redirect
+from .responders import StaticFile
+from .string_utils import decode_path_info
+from .string_utils import ensure_leading_trailing_slash
 
 
 class WhiteNoise:
-
     # Ten years is what nginx sets a max age if you use 'expires max;'
     # so we'll follow its lead
     FOREVER = 10 * 365 * 24 * 60 * 60
 
-    # Attributes that can be set by keyword args in the constructor
-    config_attrs = (
-        "autorefresh",
-        "max_age",
-        "allow_all_origins",
-        "charset",
-        "mimetypes",
-        "add_headers_function",
-        "index_file",
-        "immutable_file_test",
-    )
-    # Re-check the filesystem on every request so that any changes are
-    # automatically picked up. NOTE: For use in development only, not supported
-    # in production
-    autorefresh = False
-    max_age = 60
-    # Set 'Access-Control-Allow-Origin: *' header on all files.
-    # As these are all public static files this is safe (See
-    # https://www.w3.org/TR/cors/#security) and ensures that things (e.g
-    # webfonts in Firefox) still work as expected when your static files are
-    # served from a CDN, rather than your primary domain.
-    allow_all_origins = True
-    charset = "utf-8"
-    # Custom mime types
-    mimetypes = None
-    # Callback for adding custom logic when setting headers
-    add_headers_function = None
-    # Name of index file (None to disable index support)
-    index_file = None
+    def __init__(
+        self,
+        application,
+        root=None,
+        prefix=None,
+        *,
+        # Re-check the filesystem on every request so that any changes are
+        # automatically picked up. NOTE: For use in development only, not supported
+        # in production
+        autorefresh: bool = False,
+        max_age: int | None = 60,  # seconds
+        # Set 'Access-Control-Allow-Origin: *' header on all files.
+        # As these are all public static files this is safe (See
+        # https://www.w3.org/TR/cors/#security) and ensures that things (e.g
+        # webfonts in Firefox) still work as expected when your static files are
+        # served from a CDN, rather than your primary domain.
+        allow_all_origins: bool = True,
+        charset: str = "utf-8",
+        mimetypes: dict[str, str] | None = None,
+        add_headers_function: Callable[[Headers, str, str], None] | None = None,
+        index_file: str | bool | None = None,
+        immutable_file_test: Callable | str | None = None,
+    ):
+        self.autorefresh = autorefresh
+        self.max_age = max_age
+        self.allow_all_origins = allow_all_origins
+        self.charset = charset
+        self.add_headers_function = add_headers_function
+        if index_file is True:
+            self.index_file: str | None = "index.html"
+        elif isinstance(index_file, str):
+            self.index_file = index_file
+        else:
+            self.index_file = None
 
-    def __init__(self, application, root=None, prefix=None, **kwargs):
-        for attr in self.config_attrs:
-            try:
-                value = kwargs.pop(attr)
-            except KeyError:
-                pass
+        if immutable_file_test is not None:
+            if not callable(immutable_file_test):
+                regex = re.compile(immutable_file_test)
+                self.immutable_file_test = lambda path, url: bool(regex.search(url))
             else:
-                value = decode_if_byte_string(value)
-                setattr(self, attr, value)
-        if kwargs:
-            raise TypeError(f"Unexpected keyword argument '{list(kwargs.keys())[0]}'")
-        self.media_types = MediaTypes(extra_types=self.mimetypes)
+                self.immutable_file_test = immutable_file_test
+
+        self.media_types = MediaTypes(extra_types=mimetypes)
         self.application = application
         self.files = {}
         self.directories = []
-        if self.index_file is True:
-            self.index_file = "index.html"
-        if not callable(self.immutable_file_test):
-            regex = re.compile(self.immutable_file_test)
-            self.immutable_file_test = lambda path, url: bool(regex.search(url))
         if root is not None:
             self.add_files(root, prefix)
 
@@ -98,10 +94,8 @@ class WhiteNoise:
             return []
 
     def add_files(self, root, prefix=None):
-        root = decode_if_byte_string(root, force_text=True)
         root = os.path.abspath(root)
         root = root.rstrip(os.path.sep) + os.path.sep
-        prefix = decode_if_byte_string(prefix)
         prefix = ensure_leading_trailing_slash(prefix)
         if self.autorefresh:
             # Later calls to `add_files` overwrite earlier ones, hence we need
@@ -127,7 +121,7 @@ class WhiteNoise:
     def add_file_to_dictionary(self, url, path, stat_cache=None):
         if self.is_compressed_variant(path, stat_cache=stat_cache):
             return
-        if self.index_file and url.endswith("/" + self.index_file):
+        if self.index_file is not None and url.endswith("/" + self.index_file):
             index_url = url[: -len(self.index_file)]
             index_no_slash = index_url.rstrip("/")
             self.files[url] = self.redirect(url, index_url)
@@ -138,7 +132,7 @@ class WhiteNoise:
 
     def find_file(self, url):
         # Optimization: bail early if the URL can never match a file
-        if not self.index_file and url.endswith("/"):
+        if self.index_file is None and url.endswith("/"):
             return
         if not self.url_is_canonical(url):
             return
@@ -158,25 +152,23 @@ class WhiteNoise:
     def find_file_at_path(self, path, url):
         if self.is_compressed_variant(path):
             raise MissingFileError(path)
-        if self.index_file:
-            return self.find_file_at_path_with_indexes(path, url)
-        else:
-            return self.get_static_file(path, url)
 
-    def find_file_at_path_with_indexes(self, path, url):
-        if url.endswith("/"):
-            path = os.path.join(path, self.index_file)
-            return self.get_static_file(path, url)
-        elif url.endswith("/" + self.index_file):
-            if os.path.isfile(path):
-                return self.redirect(url, url[: -len(self.index_file)])
-        else:
-            try:
+        if self.index_file is not None:
+            if url.endswith("/"):
+                path = os.path.join(path, self.index_file)
                 return self.get_static_file(path, url)
-            except IsDirectoryError:
-                if os.path.isfile(os.path.join(path, self.index_file)):
-                    return self.redirect(url, url + "/")
-        raise MissingFileError(path)
+            elif url.endswith("/" + self.index_file):
+                if os.path.isfile(path):
+                    return self.redirect(url, url[: -len(self.index_file)])
+            else:
+                try:
+                    return self.get_static_file(path, url)
+                except IsDirectoryError:
+                    if os.path.isfile(os.path.join(path, self.index_file)):
+                        return self.redirect(url, url + "/")
+            raise MissingFileError(path)
+
+        return self.get_static_file(path, url)
 
     @staticmethod
     def url_is_canonical(url):
@@ -210,7 +202,7 @@ class WhiteNoise:
         self.add_cache_headers(headers, path, url)
         if self.allow_all_origins:
             headers["Access-Control-Allow-Origin"] = "*"
-        if self.add_headers_function:
+        if self.add_headers_function is not None:
             self.add_headers_function(headers, path, url)
         return StaticFile(
             path,
diff --git a/src/whitenoise/compress.py b/src/whitenoise/compress.py
index e4e9fba..143e1e4 100644
--- a/src/whitenoise/compress.py
+++ b/src/whitenoise/compress.py
@@ -15,7 +15,6 @@ except ImportError:  # pragma: no cover
 
 
 class Compressor:
-
     # Extensions that it's not worth trying to compress
     SKIP_COMPRESS_EXTENSIONS = (
         # Images
@@ -38,6 +37,18 @@ class Compressor:
         # Fonts
         "woff",
         "woff2",
+        # Video
+        "3gp",
+        "3gpp",
+        "asf",
+        "avi",
+        "m4v",
+        "mov",
+        "mp4",
+        "mpeg",
+        "mpg",
+        "webm",
+        "wmv",
     )
 
     def __init__(
diff --git a/src/whitenoise/media_types.py b/src/whitenoise/media_types.py
index cb27af1..d693f3a 100644
--- a/src/whitenoise/media_types.py
+++ b/src/whitenoise/media_types.py
@@ -6,12 +6,12 @@ import os
 class MediaTypes:
     __slots__ = ("types_map",)
 
-    def __init__(self, *, extra_types=None):
+    def __init__(self, *, extra_types: dict[str, str] | None = None) -> None:
         self.types_map = default_types()
         if extra_types is not None:
             self.types_map.update(extra_types)
 
-    def get_type(self, path):
+    def get_type(self, path: str) -> str:
         name = os.path.basename(path).lower()
         media_type = self.types_map.get(name)
         if media_type is not None:
@@ -20,7 +20,7 @@ class MediaTypes:
         return self.types_map.get(extension, "application/octet-stream")
 
 
-def default_types():
+def default_types() -> dict[str, str]:
     """
     We use our own set of default media types rather than the system-supplied
     ones. This ensures consistent media type behaviour across varied
diff --git a/src/whitenoise/middleware.py b/src/whitenoise/middleware.py
index 248339d..3f5a809 100644
--- a/src/whitenoise/middleware.py
+++ b/src/whitenoise/middleware.py
@@ -11,7 +11,7 @@ from django.http import FileResponse
 from django.urls import get_script_prefix
 
 from .base import WhiteNoise
-from .string_utils import decode_if_byte_string, ensure_leading_trailing_slash
+from .string_utils import ensure_leading_trailing_slash
 
 __all__ = ["WhiteNoiseMiddleware"]
 
@@ -31,42 +31,97 @@ class WhiteNoiseFileResponse(FileResponse):
 class WhiteNoiseMiddleware(WhiteNoise):
     """
     Wrap WhiteNoise to allow it to function as Django middleware, rather
-    than WSGI middleware
-
-    This functions as both old- and new-style middleware, so can be included in
-    either MIDDLEWARE or MIDDLEWARE_CLASSES.
+    than WSGI middleware.
     """
 
-    config_attrs = WhiteNoise.config_attrs + ("root", "use_finders", "static_prefix")
-    root = None
-    use_finders = False
-    static_prefix = None
-
     def __init__(self, get_response=None, settings=settings):
         self.get_response = get_response
-        self.configure_from_settings(settings)
-        # Pass None for `application`
-        super().__init__(None)
+
+        try:
+            autorefresh: bool = settings.WHITENOISE_AUTOREFRESH
+        except AttributeError:
+            autorefresh = settings.DEBUG
+        try:
+            max_age = settings.WHITENOISE_MAX_AGE
+        except AttributeError:
+            if settings.DEBUG:
+                max_age = 0
+            else:
+                max_age = 60
+        try:
+            allow_all_origins = settings.WHITENOISE_ALLOW_ALL_ORIGINS
+        except AttributeError:
+            allow_all_origins = True
+        try:
+            charset = settings.WHITENOISE_CHARSET
+        except AttributeError:
+            charset = "utf-8"
+        try:
+            mimetypes = settings.WHITENOISE_MIMETYPES
+        except AttributeError:
+            mimetypes = None
+        try:
+            add_headers_function = settings.WHITENOISE_ADD_HEADERS_FUNCTION
+        except AttributeError:
+            add_headers_function = None
+        try:
+            index_file = settings.WHITENOISE_INDEX_FILE
+        except AttributeError:
+            index_file = None
+        try:
+            immutable_file_test = settings.WHITENOISE_IMMUTABLE_FILE_TEST
+        except AttributeError:
+            immutable_file_test = None
+
+        super().__init__(
+            application=None,
+            autorefresh=autorefresh,
+            max_age=max_age,
+            allow_all_origins=allow_all_origins,
+            charset=charset,
+            mimetypes=mimetypes,
+            add_headers_function=add_headers_function,
+            index_file=index_file,
+            immutable_file_test=immutable_file_test,
+        )
+
+        try:
+            self.use_finders = settings.WHITENOISE_USE_FINDERS
+        except AttributeError:
+            self.use_finders = settings.DEBUG
+
+        try:
+            self.static_prefix = settings.WHITENOISE_STATIC_PREFIX
+        except AttributeError:
+            self.static_prefix = urlparse(settings.STATIC_URL or "").path
+            script_prefix = get_script_prefix().rstrip("/")
+            if script_prefix:
+                if self.static_prefix.startswith(script_prefix):
+                    self.static_prefix = self.static_prefix[len(script_prefix) :]
+        self.static_prefix = ensure_leading_trailing_slash(self.static_prefix)
+
+        self.static_root = settings.STATIC_ROOT
         if self.static_root:
             self.add_files(self.static_root, prefix=self.static_prefix)
-        if self.root:
-            self.add_files(self.root)
+
+        try:
+            root = settings.WHITENOISE_ROOT
+        except AttributeError:
+            root = None
+        if root:
+            self.add_files(root)
+
         if self.use_finders and not self.autorefresh:
             self.add_files_from_finders()
 
     def __call__(self, request):
-        response = self.process_request(request)
-        if response is None:
-            response = self.get_response(request)
-        return response
-
-    def process_request(self, request):
         if self.autorefresh:
             static_file = self.find_file(request.path_info)
         else:
             static_file = self.files.get(request.path_info)
         if static_file is not None:
             return self.serve(static_file, request)
+        return self.get_response(request)
 
     @staticmethod
     def serve(static_file, request):
@@ -79,30 +134,6 @@ class WhiteNoiseMiddleware(WhiteNoise):
             http_response[key] = value
         return http_response
 
-    def configure_from_settings(self, settings):
-        # Default configuration
-        self.autorefresh = settings.DEBUG
-        self.use_finders = settings.DEBUG
-        self.static_prefix = urlparse(settings.STATIC_URL or "").path
-        script_prefix = get_script_prefix().rstrip("/")
-        if script_prefix:
-            if self.static_prefix.startswith(script_prefix):
-                self.static_prefix = self.static_prefix[len(script_prefix) :]
-        if settings.DEBUG:
-            self.max_age = 0
-        # Allow settings to override default attributes
-        for attr in self.config_attrs:
-            settings_key = f"WHITENOISE_{attr.upper()}"
-            try:
-                value = getattr(settings, settings_key)
-            except AttributeError:
-                pass
-            else:
-                value = decode_if_byte_string(value)
-                setattr(self, attr, value)
-        self.static_prefix = ensure_leading_trailing_slash(self.static_prefix)
-        self.static_root = decode_if_byte_string(settings.STATIC_ROOT)
-
     def add_files_from_finders(self):
         files = {}
         for finder in finders.get_finders():
@@ -166,6 +197,6 @@ class WhiteNoiseMiddleware(WhiteNoise):
 
     def get_static_url(self, name):
         try:
-            return decode_if_byte_string(staticfiles_storage.url(name))
+            return staticfiles_storage.url(name)
         except ValueError:
             return None
diff --git a/src/whitenoise/responders.py b/src/whitenoise/responders.py
index bd21ce8..9501ea6 100644
--- a/src/whitenoise/responders.py
+++ b/src/whitenoise/responders.py
@@ -4,7 +4,8 @@ import errno
 import os
 import re
 import stat
-from email.utils import formatdate, parsedate
+from email.utils import formatdate
+from email.utils import parsedate
 from http import HTTPStatus
 from io import BufferedIOBase
 from time import mktime
@@ -218,7 +219,7 @@ class StaticFile:
             return False
         last_requested_ts = parsedate(last_requested)
         if last_requested_ts is not None:
-            return parsedate(last_requested) >= self.last_modified
+            return last_requested_ts >= self.last_modified
         return False
 
     def get_path_and_headers(self, request_headers):
diff --git a/src/whitenoise/storage.py b/src/whitenoise/storage.py
index 919ce9b..029b0ff 100644
--- a/src/whitenoise/storage.py
+++ b/src/whitenoise/storage.py
@@ -4,118 +4,51 @@ import errno
 import os
 import re
 import textwrap
+from typing import Any
+from typing import Iterator
+from typing import Tuple
+from typing import Union
 
 from django.conf import settings
-from django.contrib.staticfiles.storage import (
-    ManifestStaticFilesStorage,
-    StaticFilesStorage,
-)
+from django.contrib.staticfiles.storage import ManifestStaticFilesStorage
+from django.contrib.staticfiles.storage import StaticFilesStorage
 
 from .compress import Compressor
 
+_PostProcessT = Iterator[Union[Tuple[str, str, bool], Tuple[str, None, RuntimeError]]]
 
-class CompressedStaticFilesMixin:
+
+class CompressedStaticFilesStorage(StaticFilesStorage):
     """
-    Wraps a StaticFilesStorage instance to compress output files
+    StaticFilesStorage subclass that compresses output files.
     """
 
-    def post_process(self, *args, **kwargs):
-        super_post_process = getattr(
-            super(),
-            "post_process",
-            self.fallback_post_process,
-        )
-        files = super_post_process(*args, **kwargs)
-        if not kwargs.get("dry_run"):
-            files = self.post_process_with_compression(files)
-        return files
-
-    # Only used if the class we're wrapping doesn't implement its own
-    # `post_process` method
-    def fallback_post_process(self, paths, dry_run=False, **options):
-        if not dry_run:
-            for path in paths:
-                yield path, None, False
+    def post_process(
+        self, paths: dict[str, Any], dry_run: bool = False, **options: Any
+    ) -> _PostProcessT:
+        if dry_run:
+            return
 
-    def create_compressor(self, **kwargs):
-        return Compressor(**kwargs)
-
-    def post_process_with_compression(self, files):
         extensions = getattr(settings, "WHITENOISE_SKIP_COMPRESS_EXTENSIONS", None)
         compressor = self.create_compressor(extensions=extensions, quiet=True)
-        for name, hashed_name, processed in files:
-            yield name, hashed_name, processed
-            if isinstance(processed, Exception):
-                continue
-            unique_names = set(filter(None, [name, hashed_name]))
-            for name in unique_names:
-                if compressor.should_compress(name):
-                    path = self.path(name)
-                    prefix_len = len(path) - len(name)
-                    for compressed_path in compressor.compress(path):
-                        compressed_name = compressed_path[prefix_len:]
-                        yield name, compressed_name, True
-
-
-class CompressedStaticFilesStorage(CompressedStaticFilesMixin, StaticFilesStorage):
-    pass
-
-
-class HelpfulExceptionMixin:
-    """
-    If a CSS file contains references to images, fonts etc that can't be found
-    then Django's `post_process` blows up with a not particularly helpful
-    ValueError that leads people to think WhiteNoise is broken.
-
-    Here we attempt to intercept such errors and reformat them to be more
-    helpful in revealing the source of the problem.
-    """
-
-    ERROR_MSG_RE = re.compile("^The file '(.+)' could not be found")
-
-    ERROR_MSG = textwrap.dedent(
-        """\
-        {orig_message}
 
-        The {ext} file '{filename}' references a file which could not be found:
-          {missing}
-
-        Please check the URL references in this {ext} file, particularly any
-        relative paths which might be pointing to the wrong location.
-        """
-    )
-
-    def post_process(self, *args, **kwargs):
-        files = super().post_process(*args, **kwargs)
-        for name, hashed_name, processed in files:
-            if isinstance(processed, Exception):
-                processed = self.make_helpful_exception(processed, name)
-            yield name, hashed_name, processed
+        for path in paths:
+            if compressor.should_compress(path):
+                full_path = self.path(path)
+                prefix_len = len(full_path) - len(path)
+                for compressed_path in compressor.compress(full_path):
+                    compressed_name = compressed_path[prefix_len:]
+                    yield path, compressed_name, True
 
-    def make_helpful_exception(self, exception, name):
-        if isinstance(exception, ValueError):
-            message = exception.args[0] if len(exception.args) else ""
-            # Stringly typed exceptions. Yay!
-            match = self.ERROR_MSG_RE.search(message)
-            if match:
-                extension = os.path.splitext(name)[1].lstrip(".").upper()
-                message = self.ERROR_MSG.format(
-                    orig_message=message,
-                    filename=name,
-                    missing=match.group(1),
-                    ext=extension,
-                )
-                exception = MissingFileError(message)
-        return exception
+    def create_compressor(self, **kwargs: Any) -> Compressor:
+        return Compressor(**kwargs)
 
 
 class MissingFileError(ValueError):
     pass
 
 
-class CompressedManifestStaticFilesStorage(
-    HelpfulExceptionMixin, ManifestStaticFilesStorage
-):
+class CompressedManifestStaticFilesStorage(ManifestStaticFilesStorage):
     """
     Extends ManifestStaticFilesStorage instance to create compressed versions
     of its output files and, optionally, to delete the non-hashed files (i.e.
@@ -132,9 +65,15 @@ class CompressedManifestStaticFilesStorage(
 
     def post_process(self, *args, **kwargs):
         files = super().post_process(*args, **kwargs)
+
         if not kwargs.get("dry_run"):
             files = self.post_process_with_compression(files)
-        return files
+
+        # Make exception messages helpful
+        for name, hashed_name, processed in files:
+            if isinstance(processed, Exception):
+                processed = self.make_helpful_exception(processed, name)
+            yield name, hashed_name, processed
 
     def post_process_with_compression(self, files):
         # Files may get hashed multiple times, we want to keep track of all the
@@ -199,3 +138,41 @@ class CompressedManifestStaticFilesStorage(
                 for compressed_path in compressor.compress(path):
                     compressed_name = compressed_path[prefix_len:]
                     yield name, compressed_name
+
+    def make_helpful_exception(self, exception, name):
+        """
+        If a CSS file contains references to images, fonts etc that can't be found
+        then Django's `post_process` blows up with a not particularly helpful
+        ValueError that leads people to think WhiteNoise is broken.
+
+        Here we attempt to intercept such errors and reformat them to be more
+        helpful in revealing the source of the problem.
+        """
+        if isinstance(exception, ValueError):
+            message = exception.args[0] if len(exception.args) else ""
+            # Stringly typed exceptions. Yay!
+            match = self._error_msg_re.search(message)
+            if match:
+                extension = os.path.splitext(name)[1].lstrip(".").upper()
+                message = self._error_msg.format(
+                    orig_message=message,
+                    filename=name,
+                    missing=match.group(1),
+                    ext=extension,
+                )
+                exception = MissingFileError(message)
+        return exception
+
+    _error_msg_re = re.compile(r"^The file '(.+)' could not be found")
+
+    _error_msg = textwrap.dedent(
+        """\
+        {orig_message}
+
+        The {ext} file '{filename}' references a file which could not be found:
+          {missing}
+
+        Please check the URL references in this {ext} file, particularly any
+        relative paths which might be pointing to the wrong location.
+        """
+    )
diff --git a/src/whitenoise/string_utils.py b/src/whitenoise/string_utils.py
index b56d262..6be9062 100644
--- a/src/whitenoise/string_utils.py
+++ b/src/whitenoise/string_utils.py
@@ -1,14 +1,6 @@
 from __future__ import annotations
 
 
-def decode_if_byte_string(s, force_text=False):
-    if isinstance(s, bytes):
-        s = s.decode()
-    if force_text and not isinstance(s, str):
-        s = str(s)
-    return s
-
-
 # Follow Django in treating URLs as UTF-8 encoded (which requires undoing the
 # implicit ISO-8859-1 decoding applied in Python 3). Strictly speaking, URLs
 # should only be ASCII anyway, but UTF-8 can be found in the wild.
diff --git a/tests/django_settings.py b/tests/django_settings.py
index 7cb10fc..a571ef5 100644
--- a/tests/django_settings.py
+++ b/tests/django_settings.py
@@ -2,7 +2,10 @@ from __future__ import annotations
 
 import os.path
 
-from .utils import TEST_FILE_PATH, AppServer
+import django
+
+from .utils import AppServer
+from .utils import TEST_FILE_PATH
 
 ALLOWED_HOSTS = ["*"]
 
@@ -17,7 +20,14 @@ STATIC_URL = FORCE_SCRIPT_NAME + "/static/"
 
 STATIC_ROOT = os.path.join(TEST_FILE_PATH, "root")
 
-STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
+if django.VERSION >= (4, 2):
+    STORAGES = {
+        "staticfiles": {
+            "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
+        },
+    }
+else:
+    STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
 
 MIDDLEWARE = ["whitenoise.middleware.WhiteNoiseMiddleware"]
 
diff --git a/tests/test_django_whitenoise.py b/tests/test_django_whitenoise.py
index c35e541..2654424 100644
--- a/tests/test_django_whitenoise.py
+++ b/tests/test_django_whitenoise.py
@@ -3,20 +3,22 @@ from __future__ import annotations
 import shutil
 import tempfile
 from contextlib import closing
-from urllib.parse import urljoin, urlparse
+from urllib.parse import urljoin
+from urllib.parse import urlparse
 
-import django
 import pytest
 from django.conf import settings
-from django.contrib.staticfiles import finders, storage
+from django.contrib.staticfiles import finders
+from django.contrib.staticfiles import storage
 from django.core.management import call_command
 from django.core.wsgi import get_wsgi_application
 from django.test.utils import override_settings
 from django.utils.functional import empty
 
-from whitenoise.middleware import WhiteNoiseFileResponse, WhiteNoiseMiddleware
-
-from .utils import AppServer, Files
+from .utils import AppServer
+from .utils import Files
+from whitenoise.middleware import WhiteNoiseFileResponse
+from whitenoise.middleware import WhiteNoiseMiddleware
 
 
 def reset_lazy_object(obj):
@@ -86,9 +88,7 @@ def test_unversioned_file_not_cached_forever(server, static_files, _collect_stat
     url = settings.STATIC_URL + static_files.js_path
     response = server.get(url)
     assert response.content == static_files.js_content
-    assert response.headers.get("Cache-Control") == "max-age={}, public".format(
-        WhiteNoiseMiddleware.max_age
-    )
+    assert response.headers.get("Cache-Control") == "max-age=60, public"
 
 
 def test_get_gzip(server, static_files, _collect_static):
@@ -204,7 +204,6 @@ def test_whitenoise_file_response_has_only_one_header():
     assert headers == {"content-type"}
 
 
-@pytest.mark.skipif(django.VERSION[:2] < (3, 1), reason="feature added in Django 3.1")
 def test_relative_static_url(server, static_files, _collect_static):
     with override_settings(STATIC_URL="static/"):
         url = storage.staticfiles_storage.url(static_files.js_path)
diff --git a/tests/test_runserver_nostatic.py b/tests/test_runserver_nostatic.py
index 4614cfd..65ed458 100644
--- a/tests/test_runserver_nostatic.py
+++ b/tests/test_runserver_nostatic.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
-from django.core.management import get_commands, load_command_class
+from django.core.management import get_commands
+from django.core.management import load_command_class
 
 
 def get_command_instance(name):
diff --git a/tests/test_storage.py b/tests/test_storage.py
index beeae9b..fca9a60 100644
--- a/tests/test_storage.py
+++ b/tests/test_storage.py
@@ -6,20 +6,18 @@ import shutil
 import tempfile
 from posixpath import basename
 
+import django
 import pytest
 from django.conf import settings
-from django.contrib.staticfiles.storage import HashedFilesMixin, staticfiles_storage
+from django.contrib.staticfiles.storage import HashedFilesMixin
+from django.contrib.staticfiles.storage import staticfiles_storage
 from django.core.management import call_command
 from django.test.utils import override_settings
 from django.utils.functional import empty
 
-from whitenoise.storage import (
-    CompressedManifestStaticFilesStorage,
-    HelpfulExceptionMixin,
-    MissingFileError,
-)
-
 from .utils import Files
+from whitenoise.storage import CompressedManifestStaticFilesStorage
+from whitenoise.storage import MissingFileError
 
 
 @pytest.fixture()
@@ -38,27 +36,54 @@ def setup():
 
 @pytest.fixture()
 def _compressed_storage(setup):
-    with override_settings(
-        STATICFILES_STORAGE="whitenoise.storage.CompressedStaticFilesStorage"
-    ):
-        call_command("collectstatic", verbosity=0, interactive=False)
+    backend = "whitenoise.storage.CompressedStaticFilesStorage"
+    if django.VERSION >= (4, 2):
+        storages = {
+            "STORAGES": {
+                **settings.STORAGES,
+                "staticfiles": {"BACKEND": backend},
+            }
+        }
+    else:
+        storages = {"STATICFILES_STORAGE": backend}
+
+    with override_settings(**storages):
+        yield
 
 
 @pytest.fixture()
 def _compressed_manifest_storage(setup):
-    with override_settings(
-        STATICFILES_STORAGE="whitenoise.storage.CompressedManifestStaticFilesStorage",
-        WHITENOISE_KEEP_ONLY_HASHED_FILES=True,
-    ):
+    backend = "whitenoise.storage.CompressedManifestStaticFilesStorage"
+    if django.VERSION >= (4, 2):
+        storages = {
+            "STORAGES": {
+                **settings.STORAGES,
+                "staticfiles": {"BACKEND": backend},
+            }
+        }
+    else:
+        storages = {"STATICFILES_STORAGE": backend}
+
+    with override_settings(**storages, WHITENOISE_KEEP_ONLY_HASHED_FILES=True):
         call_command("collectstatic", verbosity=0, interactive=False)
 
 
-def test_compressed_files_are_created(_compressed_storage):
+def test_compressed_static_files_storage(_compressed_storage):
+    call_command("collectstatic", verbosity=0, interactive=False)
+
     for name in ["styles.css.gz", "styles.css.br"]:
         path = os.path.join(settings.STATIC_ROOT, name)
         assert os.path.exists(path)
 
 
+def test_compressed_static_files_storage_dry_run(_compressed_storage):
+    call_command("collectstatic", "--dry-run", verbosity=0, interactive=False)
+
+    for name in ["styles.css.gz", "styles.css.br"]:
+        path = os.path.join(settings.STATIC_ROOT, name)
+        assert not os.path.exists(path)
+
+
 def test_make_helpful_exception(_compressed_manifest_storage):
     class TriggerException(HashedFilesMixin):
         def exists(self, path):
@@ -69,7 +94,7 @@ def test_make_helpful_exception(_compressed_manifest_storage):
         TriggerException().hashed_name("/missing/file.png")
     except ValueError as e:
         exception = e
-    helpful_exception = HelpfulExceptionMixin().make_helpful_exception(
+    helpful_exception = CompressedManifestStaticFilesStorage().make_helpful_exception(
         exception, "styles/app.css"
     )
     assert isinstance(helpful_exception, MissingFileError)
diff --git a/tests/test_string_utils.py b/tests/test_string_utils.py
index 9080457..3d56524 100644
--- a/tests/test_string_utils.py
+++ b/tests/test_string_utils.py
@@ -1,21 +1,6 @@
 from __future__ import annotations
 
-from whitenoise.string_utils import decode_if_byte_string, ensure_leading_trailing_slash
-
-
-class DecodeIfByteStringTests:
-    def test_bytes(self):
-        assert decode_if_byte_string(b"abc") == "abc"
-
-    def test_unforced(self):
-        x = object()
-        assert decode_if_byte_string(x) is x
-
-    def test_forced(self):
-        x = object()
-        result = decode_if_byte_string(x, force_text=True)
-        assert isinstance(result, str)
-        assert result.startswith("<object object at ")
+from whitenoise.string_utils import ensure_leading_trailing_slash
 
 
 class EnsureLeadingTrailingSlashTests:
diff --git a/tests/test_whitenoise.py b/tests/test_whitenoise.py
index 9628736..3e98f03 100644
--- a/tests/test_whitenoise.py
+++ b/tests/test_whitenoise.py
@@ -14,11 +14,11 @@ from wsgiref.simple_server import demo_app
 
 import pytest
 
+from .utils import AppServer
+from .utils import Files
 from whitenoise import WhiteNoise
 from whitenoise.responders import StaticFile
 
-from .utils import AppServer, Files
-
 
 @pytest.fixture(scope="module")
 def files():
@@ -71,13 +71,6 @@ def server(application):
         yield app_server
 
 
-def test_invalid_kwarg():
-    with pytest.raises(TypeError) as excinfo:
-        WhiteNoise(None, invalid=True)
-
-    assert excinfo.value.args == ("Unexpected keyword argument 'invalid'",)
-
-
 def assert_is_default_response(response):
     assert "Hello world!" in response.text
 
@@ -342,13 +335,27 @@ def test_directory_path_can_be_pathlib_instance():
     WhiteNoise(None, root=root, autorefresh=True)
 
 
-def test_last_modified_not_set_when_mtime_is_zero():
-    class FakeStatEntry:
-        st_mtime = 0
-        st_size = 1024
-        st_mode = stat.S_IFREG
+def fake_stat_entry(
+    st_mode: int = stat.S_IFREG, st_size: int = 1024, st_mtime: int = 0
+) -> os.stat_result:
+    return os.stat_result(
+        (
+            st_mode,
+            0,  # st_ino
+            0,  # st_dev
+            0,  # st_nlink
+            0,  # st_uid
+            0,  # st_gid
+            st_size,
+            0,  # st_atime
+            st_mtime,
+            0,  # st_ctime
+        )
+    )
+
 
-    stat_cache = {__file__: FakeStatEntry()}
+def test_last_modified_not_set_when_mtime_is_zero():
+    stat_cache = {__file__: fake_stat_entry()}
     responder = StaticFile(__file__, [], stat_cache=stat_cache)
     response = responder.get_response("GET", {})
     response.file.close()
@@ -358,12 +365,7 @@ def test_last_modified_not_set_when_mtime_is_zero():
 
 
 def test_file_size_matches_range_with_range_header():
-    class FakeStatEntry:
-        st_mtime = 0
-        st_size = 1024
-        st_mode = stat.S_IFREG
-
-    stat_cache = {__file__: FakeStatEntry()}
+    stat_cache = {__file__: fake_stat_entry()}
     responder = StaticFile(__file__, [], stat_cache=stat_cache)
     response = responder.get_response("GET", {"HTTP_RANGE": "bytes=0-13"})
     file_size = len(response.file.read())
@@ -371,15 +373,11 @@ def test_file_size_matches_range_with_range_header():
 
 
 def test_chunked_file_size_matches_range_with_range_header():
-    class FakeStatEntry:
-        st_mtime = 0
-        st_size = 1024
-        st_mode = stat.S_IFREG
-
-    stat_cache = {__file__: FakeStatEntry()}
+    stat_cache = {__file__: fake_stat_entry()}
     responder = StaticFile(__file__, [], stat_cache=stat_cache)
     response = responder.get_response("GET", {"HTTP_RANGE": "bytes=0-13"})
     file_size = 0
+    assert response.file is not None
     while response.file.read(1):
         file_size += 1
     assert file_size == 14
diff --git a/tests/utils.py b/tests/utils.py
index e241b31..0db45d7 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -2,15 +2,12 @@ from __future__ import annotations
 
 import os
 import threading
-import warnings
-from wsgiref.simple_server import WSGIRequestHandler, make_server
+from wsgiref.simple_server import make_server
+from wsgiref.simple_server import WSGIRequestHandler
 from wsgiref.util import shift_path_info
 
 import requests
 
-warnings.filterwarnings(action="ignore", category=DeprecationWarning, module="requests")
-
-
 TEST_FILE_PATH = os.path.join(os.path.dirname(__file__), "test_files")
 
 
diff --git a/tox.ini b/tox.ini
index 61f9af5..3591026 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,24 +1,27 @@
 [tox]
 envlist =
-  py37-django{22,30,31,32}
-  py38-django{22,30,31,32,40}
-  py39-django{22,30,31,32,40}
-  py310-django{32,40}
-  py310-codegen
+    py37-django{32}
+    py38-django{32,40,41,42}
+    py39-django{32,40,41,42}
+    py310-django{32,40,41,42}
+    py311-django{41,42}
+    py311-codegen
 
 [testenv]
+# Ignoring 'path is deprecated' from certifi: https://github.com/certifi/python-certifi/issues/192
 commands =
   python \
     -W error::ResourceWarning \
     -W error::DeprecationWarning \
     -W error::PendingDeprecationWarning \
+    -W 'ignore:path is deprecated. Use files() instead.:DeprecationWarning' \
     -m coverage run \
     -m pytest {posargs:tests}
 deps = -r requirements/{envname}.txt
 setenv =
-  PYTHONDEVMODE=1
+    PYTHONDEVMODE=1
 
-[testenv:py310-codegen]
+[testenv:py311-codegen]
 commands =
     python ./scripts/generate_default_media_types.py --check
 deps =

More details

Full run details

Historical runs