├── .editorconfig
├── .gitattributes
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── config.yml
│ ├── feature-request.yml
│ └── issue.yml
├── SECURITY.md
├── pull_request_template.md
└── workflows
│ └── ci.yml
├── .gitignore
├── .pre-commit-config.yaml
├── .prettierrc
├── .vscode
└── extensions.json
├── CHANGELOG.md
├── LICENSE.md
├── README.md
├── docs
├── mkdocs.yml
└── src
│ ├── asgi.md
│ ├── assets
│ └── css
│ │ └── main.css
│ ├── changelog.md
│ ├── contributing.md
│ ├── dictionary.txt
│ ├── django-faq.md
│ ├── django-settings.md
│ ├── django.md
│ ├── index.md
│ ├── license.md
│ ├── quick-start.md
│ ├── servestatic-asgi.md
│ ├── servestatic.md
│ └── wsgi.md
├── pyproject.toml
├── scripts
├── generate_default_media_types.py
└── validate_changelog.py
├── src
└── servestatic
│ ├── __init__.py
│ ├── asgi.py
│ ├── base.py
│ ├── compress.py
│ ├── media_types.py
│ ├── middleware.py
│ ├── responders.py
│ ├── runserver_nostatic
│ ├── __init__.py
│ └── management
│ │ ├── __init__.py
│ │ └── commands
│ │ ├── __init__.py
│ │ └── runserver.py
│ ├── storage.py
│ ├── utils.py
│ └── wsgi.py
└── tests
├── __init__.py
├── conftest.py
├── django_settings.py
├── django_urls.py
├── middleware.py
├── test_asgi.py
├── test_compress.py
├── test_django_servestatic.py
├── test_files
├── assets
│ ├── compressed.css
│ ├── compressed.css.gz
│ ├── custom-mime.foobar
│ ├── subdir
│ │ └── javascript.js
│ └── with-index
│ │ └── index.html
├── root
│ └── robots.txt
└── static
│ ├── app.js
│ ├── directory
│ ├── .keep
│ └── pixel.gif
│ ├── large-file.txt
│ ├── nonascii✓.txt
│ ├── styles.css
│ └── with-index
│ └── index.html
├── test_media_types.py
├── test_runserver_nostatic.py
├── test_servestatic.py
├── test_storage.py
├── test_string_utils.py
└── utils.py
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 |
3 | root = true
4 |
5 | [*]
6 | indent_style = space
7 | indent_size = 2
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 | charset = utf-8
11 | end_of_line = lf
12 |
13 | [*.py]
14 | indent_size = 4
15 | max_line_length = 120
16 |
17 | [*.md]
18 | indent_size = 4
19 |
20 | [*.html]
21 | max_line_length = off
22 |
23 | [*.js]
24 | max_line_length = off
25 |
26 | [*.css]
27 | indent_size = 4
28 | max_line_length = off
29 |
30 | # Tests can violate line width restrictions in the interest of clarity.
31 | [**/test_*.py]
32 | max_line_length = off
33 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [archmonger]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
13 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature-request.yml:
--------------------------------------------------------------------------------
1 | name: Feature Request
2 | description: Request an enhancement or new feature.
3 | body:
4 | - type: textarea
5 | id: description
6 | attributes:
7 | label: Description
8 | description: Please describe your feature request with appropriate detail.
9 | validations:
10 | required: true
11 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/issue.yml:
--------------------------------------------------------------------------------
1 | name: Issue
2 | description: File an issue
3 | body:
4 | - type: input
5 | id: python_version
6 | attributes:
7 | label: Python Version
8 | description: Which version of Python were you using?
9 | placeholder: 3.9.0
10 | validations:
11 | required: false
12 | - type: input
13 | id: django_version
14 | attributes:
15 | label: Django Version
16 | description: Which version of Django were you using?
17 | placeholder: 3.2.0
18 | validations:
19 | required: false
20 | - type: input
21 | id: package_version
22 | attributes:
23 | label: Package Version
24 | description: Which version of this package were you using? If not the latest version, please check this issue has not since been resolved.
25 | placeholder: 1.0.0
26 | validations:
27 | required: false
28 | - type: textarea
29 | id: description
30 | attributes:
31 | label: Description
32 | description: Please describe your issue.
33 | validations:
34 | required: true
35 |
--------------------------------------------------------------------------------
/.github/SECURITY.md:
--------------------------------------------------------------------------------
1 | Please report security issues directly over email to archiethemonger@gmail.com
2 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ## Description
2 |
3 |
4 |
5 | ## Checklist
6 |
7 | Please update this checklist as you complete each item:
8 |
9 | - [ ] Tests have been developed for bug fixes or new functionality.
10 | - [ ] The changelog has been updated, if necessary.
11 | - [ ] Documentation has been updated, if necessary.
12 | - [ ] GitHub Issues closed by this PR have been linked.
13 |
14 | By submitting this pull request I agree that all contributions comply with this project's open source license(s).
15 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - main
10 | release:
11 | types: [published]
12 |
13 | jobs:
14 | lint-python:
15 | name: Lint Python
16 | runs-on: ubuntu-latest
17 | permissions:
18 | contents: read
19 | steps:
20 | - uses: actions/checkout@v4
21 | - name: Set up Python 3.x
22 | uses: actions/setup-python@v5
23 | with:
24 | python-version: "3.x"
25 | cache: "pip"
26 | - name: Install Hatch
27 | run: |
28 | pip3 --quiet install --upgrade hatch uv
29 | hatch --version
30 | uv --version
31 | - name: Run formatter
32 | run: |
33 | hatch fmt --check
34 | - name: Run type checker
35 | run: |
36 | hatch run python:type_check
37 | - name: Check files with pre-commit
38 | uses: pre-commit/action@v3.0.1
39 |
40 | lint-docs:
41 | name: Lint Documentation
42 | runs-on: ubuntu-latest
43 | permissions:
44 | contents: read
45 | steps:
46 | - uses: actions/checkout@v4
47 | with:
48 | fetch-depth: 0
49 | - uses: actions/setup-python@v5
50 | with:
51 | python-version: 3.x
52 | cache: pip
53 | - name: Install dependencies
54 | run: |
55 | pip install --upgrade hatch uv
56 | - name: Check documentation links
57 | run: |
58 | hatch run docs:linkcheck
59 | - name: Check docs build
60 | run: |
61 | hatch run docs:build
62 | - name: Validate changelog format
63 | run: |
64 | hatch run scripts/validate_changelog.py
65 |
66 | test-python:
67 | name: Python ${{ matrix.python-version }} on ${{ matrix.os }}
68 | runs-on: ${{ matrix.os }}
69 | needs:
70 | - lint-python
71 |
72 | strategy:
73 | matrix:
74 | os:
75 | - ubuntu-latest
76 | - windows-latest
77 | python-version:
78 | - "3.9"
79 | - "3.10"
80 | - "3.11"
81 | - "3.12"
82 | - "3.13"
83 |
84 | steps:
85 | - uses: actions/checkout@v4
86 |
87 | - uses: actions/setup-python@v5
88 | with:
89 | python-version: ${{ matrix.python-version }}
90 | allow-prereleases: true
91 | cache: pip
92 |
93 | - name: Install dependencies
94 | run: |
95 | python -m pip install --upgrade pip hatch uv
96 |
97 | - name: Show environment
98 | run: |
99 | hatch test --show --python ${{ matrix.python-version }}
100 |
101 | - name: Run tests
102 | run: |
103 | hatch test --cover --python ${{ matrix.python-version }}
104 | mv .coverage ".coverage.py${{ matrix.python-version }}"
105 |
106 | - name: Upload coverage data
107 | if: matrix.os != 'windows-latest'
108 | uses: actions/upload-artifact@v4
109 | with:
110 | name: "coverage-data-py${{ matrix.python-version }}"
111 | path: ".coverage.py${{ matrix.python-version }}"
112 | if-no-files-found: error
113 | include-hidden-files: true
114 | retention-days: 7
115 |
116 | build-python:
117 | name: Build Python
118 | runs-on: ubuntu-latest
119 | permissions:
120 | contents: read
121 | needs:
122 | - lint-python
123 | steps:
124 | - uses: actions/checkout@v4
125 | - name: Set up Python 3.x
126 | uses: actions/setup-python@v5
127 | with:
128 | python-version: "3.x"
129 | cache: "pip"
130 | - name: Install Hatch
131 | run: |
132 | pip3 --quiet install --upgrade hatch uv
133 | hatch --version
134 | uv --version
135 | - name: Build release files
136 | run: |
137 | hatch build --clean
138 | - uses: actions/upload-artifact@v4
139 | with:
140 | name: artifacts
141 | path: dist/*
142 | if-no-files-found: error
143 | retention-days: 7
144 |
145 | coverage-python:
146 | name: Check Python Coverage
147 | runs-on: ubuntu-latest
148 | needs:
149 | - test-python
150 | steps:
151 | - uses: actions/checkout@v4
152 |
153 | - uses: actions/setup-python@v5
154 | with:
155 | python-version: "3.x"
156 | cache: pip
157 |
158 | - name: Install dependencies
159 | run: python -m pip install --upgrade coverage[toml]
160 |
161 | - name: Download data
162 | uses: actions/download-artifact@v4
163 | with:
164 | merge-multiple: true
165 |
166 | - name: Combine coverage and fail if it's <95%
167 | run: |
168 | python -m coverage combine
169 | python -m coverage html --skip-covered --skip-empty
170 | python -m coverage report --fail-under=95
171 |
172 | - name: Upload HTML report
173 | uses: actions/upload-artifact@v4
174 | with:
175 | name: coverage-report
176 | path: htmlcov
177 |
178 | publish-docs:
179 | name: Publish Documentation
180 | runs-on: ubuntu-latest
181 | if: github.ref_name == 'main' || startsWith(github.ref, 'refs/tags/')
182 | needs:
183 | - lint-docs
184 | permissions:
185 | contents: write
186 | concurrency:
187 | group: publish-docs
188 | steps:
189 | - uses: actions/checkout@v4
190 | with:
191 | fetch-depth: 0
192 | - uses: actions/setup-python@v5
193 | with:
194 | python-version: 3.x
195 | cache: pip
196 | - name: Install dependencies
197 | run: |
198 | pip install --upgrade hatch uv
199 | - name: Configure Git
200 | run: |
201 | git config user.name github-actions
202 | git config user.email github-actions@github.com
203 | - name: Publish Develop Docs
204 | if: github.ref_name == 'main'
205 | run: |
206 | hatch run docs:deploy_develop
207 | - name: Publish Develop Docs
208 | if: startsWith(github.ref, 'refs/tags/')
209 | run: |
210 | hatch run docs:deploy_latest ${{ github.ref_name }}
211 |
212 | # This workflow relies on the user manually creating a "stub release" on GitHub with the correct version number in the tag.
213 | publish-github:
214 | name: Publish GitHub Release
215 | runs-on: ubuntu-latest
216 | if: startsWith(github.ref, 'refs/tags/')
217 | permissions:
218 | contents: write
219 | concurrency:
220 | group: publish-github
221 | needs:
222 | - build-python
223 | - coverage-python
224 | - publish-docs
225 | steps:
226 | - uses: actions/checkout@v4
227 | with:
228 | fetch-depth: 0
229 | - uses: actions/download-artifact@v4
230 | with:
231 | name: artifacts
232 | path: dist
233 | - name: Get latest release info
234 | id: query-release-info
235 | uses: release-flow/keep-a-changelog-action@v3
236 | with:
237 | command: query
238 | version: ${{ github.ref_name }}
239 | - name: Display release info
240 | run: |
241 | echo "Version: ${{ steps.query-release-info.outputs.version }}"
242 | echo "Date: ${{ steps.query-release-info.outputs.release-date }}"
243 | echo "${{ steps.query-release-info.outputs.release-notes }}"
244 | - uses: ncipollo/release-action@v1
245 | with:
246 | artifacts: "dist/*.tar.gz,dist/*.whl"
247 | body: ${{ steps.query-release-info.outputs.release-notes }}
248 | allowUpdates: true
249 |
250 | publish-pypi:
251 | name: Publish PyPi Package
252 | runs-on: ubuntu-latest
253 | if: startsWith(github.ref, 'refs/tags/')
254 | permissions:
255 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
256 | concurrency:
257 | group: publish-pypi
258 | needs:
259 | - publish-github
260 | steps:
261 | - uses: actions/download-artifact@v4
262 | with:
263 | name: artifacts
264 | path: dist
265 | - name: Publish build to PyPI
266 | uses: pypa/gh-action-pypi-publish@release/v1
267 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Django #
2 | logs
3 | *.log
4 | *.pot
5 | *.pyc
6 | .dccachea
7 | __pycache__
8 | *.sqlite3
9 | *.sqlite3-journal
10 | media
11 | cache
12 | static-deploy
13 | data
14 | settings.json
15 |
16 | # Backup files #
17 | *.bak
18 |
19 | # If you are using PyCharm #
20 | .idea/**/workspace.xml
21 | .idea/**/tasks.xml
22 | .idea/dictionaries
23 | .idea/**/dataSources/
24 | .idea/**/dataSources.ids
25 | .idea/**/dataSources.xml
26 | .idea/**/dataSources.local.xml
27 | .idea/**/sqlDataSources.xml
28 | .idea/**/dynamic.xml
29 | .idea/**/uiDesigner.xml
30 | .idea/**/gradle.xml
31 | .idea/**/libraries
32 | *.iws /out/
33 |
34 | # Python #
35 | *.py[cod]
36 | *$py.class
37 |
38 | # Distribution / packaging
39 | build/
40 | .Python build/
41 | develop-eggs/
42 | dist/
43 | downloads/
44 | eggs/
45 | .eggs/
46 | lib/
47 | lib64/
48 | parts/
49 | sdist/
50 | var/
51 | wheels/
52 | *.egg-info/
53 | .installed.cfg
54 | *.egg
55 | *.manifest
56 | *.spec
57 | MANIFEST
58 |
59 | # Installer logs
60 | pip-log.txt
61 | pip-delete-this-directory.txt
62 |
63 | # Unit test / coverage reports
64 | htmlcov/
65 | .tox/
66 | .nox/
67 | .coverage
68 | .coverage.*
69 | .cache
70 | .pytest_cache/
71 | nosetests.xml
72 | coverage.xml
73 | *.cover
74 | .hypothesis/
75 |
76 | # Linter cache
77 | .ruff_cache/
78 |
79 | # Jupyter Notebook
80 | .ipynb_checkpoints
81 |
82 | # pyenv
83 | .python-version
84 |
85 | # celery
86 | celerybeat-schedule.*
87 |
88 | # SageMath parsed files
89 | *.sage.py
90 |
91 | # Environments
92 | .env*/
93 | .venv*/
94 | env/
95 | venv/
96 | ENV/
97 | env.bak/
98 | venv.bak/
99 |
100 | # mkdocs documentation
101 | /site
102 | /docs/site/
103 |
104 | # mypy
105 | .mypy_cache/
106 |
107 | # Sublime Text #
108 | *.tmlanguage.cache
109 | *.tmPreferences.cache
110 | *.stTheme.cache
111 | *.sublime-workspace
112 | *.sublime-project
113 |
114 | # sftp configuration file
115 | sftp-config.json
116 |
117 | # Package control specific files Package
118 | Control.last-run
119 | Control.ca-list
120 | Control.ca-bundle
121 | Control.system-ca-bundle
122 | GitHub.sublime-settings
123 |
124 | # Visual Studio Code #
125 | .vscode/settings.json
126 | .vscode/tasks.json
127 | .vscode/launch.json
128 | .history
129 | %SystemDrive%
130 |
131 | # Mac file system
132 | .DS_Store
133 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | ci:
2 | autoupdate_schedule: monthly
3 |
4 | repos:
5 | - repo: https://github.com/pre-commit/pre-commit-hooks
6 | rev: "v5.0.0"
7 | hooks:
8 | - id: check-added-large-files
9 | - id: check-case-conflict
10 | - id: check-json
11 | - id: check-merge-conflict
12 | - id: check-symlinks
13 | - id: check-toml
14 | - id: end-of-file-fixer
15 | - id: trailing-whitespace
16 | - repo: https://github.com/asottile/pyupgrade
17 | rev: "v3.20.0"
18 | hooks:
19 | - id: pyupgrade
20 | args: [--py39-plus]
21 | - repo: https://github.com/hadialqattan/pycln
22 | rev: "v2.5.0"
23 | hooks:
24 | - id: pycln
25 | args: [--all]
26 | - repo: https://github.com/adamchainz/django-upgrade
27 | rev: "1.25.0"
28 | hooks:
29 | - id: django-upgrade
30 | args: [--target-version, "4.2"]
31 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "proseWrap": "never",
3 | "trailingComma": "all"
4 | }
5 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "charliermarsh.ruff",
4 | "editorconfig.editorconfig",
5 | "esbenp.prettier-vscode",
6 | "tamasfe.even-better-toml",
7 | "ms-python.python",
8 | "ms-python.vscode-pylance"
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6 |
7 |
13 |
14 | ## [Unreleased]
15 |
16 | - Nothing (yet!)
17 |
18 | ## [3.1.0] - 2025-06-10
19 |
20 | ### Added
21 |
22 | - Added support for running `ServeStatic` in standalone WSGI/ASGI mode (without an app).
23 |
24 | ## [3.0.2] - 2025-06-03
25 |
26 | ### Fixed
27 |
28 | - Fixed a bug where `ServeStaticASGI` was preventing compatibility with the `lifespan` protocol. All non-HTTP requests are now properly forwarded to the user's ASGI app.
29 |
30 | ## [3.0.1] - 2025-03-02
31 |
32 | ### Fixed
33 |
34 | - Fixed compatibility between the two following Django settings: `SERVESTATIC_KEEP_ONLY_HASHED_FILES` and `SERVESTATIC_USE_MANIFEST`
35 |
36 | ## [3.0.0] - 2025-01-10
37 |
38 | ### Changed
39 |
40 | - Drop Django 3.2 and 4.1 support.
41 | - Any errors from threads in the `servestatic.compress` command are now raised.
42 | - Compression code has been refactored to match upstream (WhiteNoise).
43 |
44 | ### Fixed
45 |
46 | - Add `asgiref` to dependencies to fix import error.
47 |
48 | ## [2.1.1] - 2024-10-27
49 |
50 | ### Fixed
51 |
52 | - Make sure WSGI `SlicedFile` is closed properly to prevent subtle bugs.
53 |
54 | ## [2.1.0] - 2024-10-02
55 |
56 | ### Added
57 |
58 | - Support Python 3.13.
59 |
60 | ### Changed
61 |
62 | - Query strings are now preserved during HTTP redirection.
63 |
64 | ## [2.0.1] - 2024-09-13
65 |
66 | ### Fixed
67 |
68 | - Fix crash when running `manage.py collectstatic` when Django's `settings.py:STATIC_ROOT` is a `Path` object.
69 |
70 | ## [2.0.0] - 2024-09-12
71 |
72 | ### Added
73 |
74 | - Django `settings.py:SERVESTATIC_USE_MANIFEST` will allow ServeStatic to use the Django manifest rather than scanning the filesystem.
75 | - When also using ServeStatic's `CompressedManifestStaticFilesStorage` backend, ServeStatic will no longer need to call `os.stat`.
76 |
77 | ### Changed
78 |
79 | - Minimum python version is now 3.9.
80 | - Django `setings.py:SERVESTATIC_USE_FINDERS` will now discover files strictly using the [finders API](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#finders-module). Previously, ServeStatic would also scan `settings.py:STATIC_ROOT` for files not found by the finders API.
81 | - Async file reading is now done via threads rather than [`aiofiles`](https://github.com/Tinche/aiofiles) due [recent performance tests](https://github.com/mosquito/aiofile/issues/88#issuecomment-2314380621).
82 | - `BaseServeStatic` has been renamed to `ServeStaticBase`.
83 | - `AsgiFileServer` has been renamed to `FileServerASGI`.
84 | - Lots of internal refactoring to improve performance, code quality, and maintainability.
85 |
86 | ## [1.2.0] - 2024-08-30
87 |
88 | ### Added
89 |
90 | - Verbose Django `404` error page when `settings.py:DEBUG` is `True`
91 |
92 | ### Fixed
93 |
94 | - Fix Django compatibility with third-party sync middleware.
95 | - ServeStatic Django middleware now only runs in async mode to avoid clashing with Django's internal usage of `asgiref.AsyncToSync`.
96 | - Respect Django `settings.py:FORCE_SCRIPT_NAME` configuration value.
97 |
98 | ## [1.1.0] - 2024-08-27
99 |
100 | ### Added
101 |
102 | - Files are now compressed within a thread pool to increase performance.
103 |
104 | ### Fixed
105 |
106 | - Fix Django `StreamingHttpResponse must consume synchronous iterators` warning.
107 | - Fix Django bug where file paths could fail to be followed on Windows.
108 |
109 | ## [1.0.0] - 2024-05-08
110 |
111 | ### Changed
112 |
113 | - Forked from [`whitenoise`](https://github.com/evansd/whitenoise) to add ASGI support.
114 |
115 | [Unreleased]: https://github.com/Archmonger/ServeStatic/compare/3.1.0...HEAD
116 | [3.1.0]: https://github.com/Archmonger/ServeStatic/compare/3.0.2...3.1.0
117 | [3.0.2]: https://github.com/Archmonger/ServeStatic/compare/3.0.1...3.0.2
118 | [3.0.1]: https://github.com/Archmonger/ServeStatic/compare/3.0.0...3.0.1
119 | [3.0.0]: https://github.com/Archmonger/ServeStatic/compare/2.1.1...3.0.0
120 | [2.1.1]: https://github.com/Archmonger/ServeStatic/compare/2.1.0...2.1.1
121 | [2.1.0]: https://github.com/Archmonger/ServeStatic/compare/2.0.1...2.1.0
122 | [2.0.1]: https://github.com/Archmonger/ServeStatic/compare/2.0.0...2.0.1
123 | [2.0.0]: https://github.com/Archmonger/ServeStatic/compare/1.2.0...2.0.0
124 | [1.2.0]: https://github.com/Archmonger/ServeStatic/compare/1.1.0...1.2.0
125 | [1.1.0]: https://github.com/Archmonger/ServeStatic/compare/1.0.0...1.1.0
126 | [1.0.0]: https://github.com/Archmonger/ServeStatic/releases/tag/1.0.0
127 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2024 Mark Bakhit
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # ServeStatic
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | _Production-grade static file server for Python WSGI & ASGI._
21 |
22 | _This project is a fork of [WhiteNoise](https://github.com/evansd/whitenoise) for [ASGI support, bug fixes, new features, and performance upgrades](https://archmonger.github.io/ServeStatic/latest/changelog/)._
23 |
24 | ---
25 |
26 | `ServeStatic` simplifies static file serving for web apps with minimal lines of configuration. It transforms your app into a self-contained unit, without relying on external services like nginx or Amazon S3. This can simplify any production deployment, but is especially useful for platforms like Heroku, OpenShift, and other PaaS providers.
27 |
28 | It is designed to work seamlessly with CDNs to ensure high performance for traffic-intensive sites, and is compatible with any ASGI/WSGI app. Extra features and auto-configuration are available for [Django](https://www.djangoproject.com/) users.
29 |
30 | Best practices are automatically handled by `ServeStatic`, such as:
31 |
32 | - Automatically serving compressed content
33 | - Proper handling of `Accept-Encoding` and `Vary` headers
34 | - Setting far-future cache headers for immutable static files.
35 |
36 | To get started or learn more about `ServeStatic`, visit the [documentation](https://archmonger.github.io/ServeStatic/).
37 |
38 | ## Frequently Asked Questions
39 |
40 | ### Isn't serving static files from Python horribly inefficient?
41 |
42 | The short answer to this is that if you care about performance and efficiency then you should be using `ServeStatic` behind a CDN like CloudFront. If you're doing _that_ then, because of the caching headers `ServeStatic` sends, the vast majority of static requests will be served directly by the CDN without touching your application, so it really doesn't make much difference how efficient `ServeStatic` is.
43 |
44 | That said, `ServeStatic` is pretty efficient. Because it only has to serve a fixed set of files it does all the work of finding files and determining the correct headers upfront on initialization. Requests can then be served with little more than a dictionary lookup to find the appropriate response. Also, when used with gunicorn (and most other WSGI servers) the actual business of pushing the file down the network interface is handled by the kernel's very efficient `sendfile` syscall, not by Python.
45 |
46 | ### Shouldn't I be pushing my static files to S3 using something like Django-Storages?
47 |
48 | No, you shouldn't. The main problem with this approach is that Amazon S3 cannot currently selectively serve compressed content to your users. Compression (using either the venerable gzip or the more modern brotli algorithms) can make dramatic reductions in the bandwidth required for your CSS and JavaScript. But in order to do this correctly the server needs to examine the `Accept-Encoding` header of the request to determine which compression formats are supported, and return an appropriate `Vary` header so that intermediate caches know to do the same. This is exactly what `ServeStatic` does, but Amazon S3 currently provides no means of doing this.
49 |
50 | The second problem with a push-based approach to handling static files is that it adds complexity and fragility to your deployment process: extra libraries specific to your storage backend, extra configuration and authentication keys, and extra tasks that must be run at specific points in the deployment in order for everything to work. With the CDN-as-caching-proxy approach that `ServeStatic` takes there are just two bits of configuration: your application needs the URL of the CDN, and the CDN needs the URL of your application. Everything else is just standard HTTP semantics. This makes your deployments simpler, your life easier, and you happier.
51 |
52 | ### What's the point in `ServeStatic` when I can do the same thing in a few lines of `apache`/`nginx`?
53 |
54 | There are two answers here. One is that ServeStatic is designed to work in situations where `apache`, `nginx`, and the like aren't easily available. But more importantly, it's easy to underestimate what's involved in serving static files correctly. Does your few lines of nginx configuration distinguish between files which might change and files which will never change and set the cache headers appropriately? Did you add the right CORS headers so that your fonts load correctly when served via a CDN? Did you turn on the special nginx setting which allows it to send gzip content in response to an `HTTP/1.0` request, which for some reason CloudFront still uses? Did you install the extension which allows you to serve brotli-encoded content to modern browsers?
55 |
56 | None of this is rocket science, but it's fiddly and annoying and `ServeStatic` takes care of all it for you.
57 |
58 |
59 |
--------------------------------------------------------------------------------
/docs/mkdocs.yml:
--------------------------------------------------------------------------------
1 | ---
2 | nav:
3 | - Home: index.md
4 | - Installation:
5 | - Quick Start: quick-start.md
6 | - Install ServeStatic on your...:
7 | - ASGI Project: asgi.md
8 | - WSGI Project: wsgi.md
9 | - Django Project: django.md
10 | - Reference:
11 | - ServeStaticASGI: servestatic-asgi.md
12 | - ServeStatic: servestatic.md
13 | - Django:
14 | - Settings: django-settings.md
15 | - FAQ: django-faq.md
16 | - About:
17 | - Changelog: changelog.md
18 | - Contributor Guide: contributing.md
19 | - Community:
20 | - GitHub Discussions: https://github.com/Archmonger/ServeStatic/discussions
21 | - License: license.md
22 |
23 | theme:
24 | name: material
25 | palette:
26 | - media: "(prefers-color-scheme: dark)"
27 | scheme: slate
28 | toggle:
29 | icon: material/white-balance-sunny
30 | name: Switch to light mode
31 | primary: black
32 | accent: blue
33 | - media: "(prefers-color-scheme: light)"
34 | scheme: default
35 | toggle:
36 | icon: material/weather-night
37 | name: Switch to dark mode
38 | primary: black
39 | accent: blue
40 | features:
41 | - navigation.instant
42 | - navigation.tabs
43 | - navigation.tabs.sticky
44 | - navigation.top
45 | - content.code.copy
46 | - search.highlight
47 | icon:
48 | repo: fontawesome/brands/github
49 | admonition:
50 | note: fontawesome/solid/note-sticky
51 |
52 | markdown_extensions:
53 | - toc:
54 | permalink: true
55 | - pymdownx.emoji:
56 | emoji_index: !!python/name:material.extensions.emoji.twemoji
57 | emoji_generator: !!python/name:material.extensions.emoji.to_svg
58 | - pymdownx.tabbed:
59 | alternate_style: true
60 | - pymdownx.highlight:
61 | linenums: true
62 | - pymdownx.superfences
63 | - pymdownx.details
64 | - pymdownx.inlinehilite
65 | - admonition
66 | - attr_list
67 | - md_in_html
68 | - pymdownx.keys
69 |
70 | plugins:
71 | - search
72 | - include-markdown
73 | - git-authors
74 | - minify:
75 | minify_html: true
76 | minify_js: true
77 | minify_css: true
78 | cache_safe: true
79 | - git-revision-date-localized:
80 | fallback_to_build_date: true
81 | - spellcheck:
82 | known_words: dictionary.txt
83 | allow_unicode: no
84 | ignore_code: yes
85 |
86 | extra:
87 | generator: false
88 | version:
89 | provider: mike
90 |
91 | extra_css:
92 | - assets/css/main.css
93 |
94 | watch:
95 | - ../docs
96 | - ../README.md
97 | - ../CHANGELOG.md
98 | - ../LICENSE.md
99 |
100 | site_name: ServeStatic
101 | site_author: Mark Bakhit (Archmonger)
102 | site_description: Production-grade static file server for Python WSGI & ASGI.
103 | copyright: '© Mark Bakhit (Archmonger)'
104 | repo_url: https://github.com/Archmonger/ServeStatic
105 | site_url: https://archmonger.github.io/ServeStatic/
106 | repo_name: ServeStatic (GitHub)
107 | edit_uri: edit/main/docs/src
108 | docs_dir: src
109 |
--------------------------------------------------------------------------------
/docs/src/asgi.md:
--------------------------------------------------------------------------------
1 | # Using ServeStatic with ASGI apps
2 |
3 | !!! tip
4 |
5 | `ServeStaticASGI` inherits its interface and features from the [WSGI variant](wsgi.md).
6 |
7 | To enable ServeStatic you need to wrap your existing ASGI application in a `ServeStaticASGI` instance and tell it where to find your static files. For example:
8 |
9 | ```python
10 | from servestatic import ServeStaticASGI
11 |
12 | from my_project import MyASGIApp
13 |
14 | application = MyASGIApp()
15 | application = ServeStaticASGI(application, root="/path/to/static/files")
16 | application.add_files("/path/to/more/static/files", prefix="more-files/")
17 | ```
18 |
19 | If you would rather use ServeStatic as a standalone file server, you can simply not provide an ASGI app, such as via `#!python ServeStaticASGI(None, root="/path/to/static/files")`.
20 |
21 | {% include-markdown "./wsgi.md" start="" end="" %}
22 |
23 | After configuring ServeStatic, you can use your favourite ASGI server (such as [`uvicorn`](https://pypi.org/project/uvicorn/), [`hypercorn`](https://pypi.org/project/Hypercorn/), or [`nginx-unit`](https://unit.nginx.org/)) to run your application.
24 |
25 | See the [API reference documentation](servestatic-asgi.md) for detailed usage and features.
26 |
--------------------------------------------------------------------------------
/docs/src/assets/css/main.css:
--------------------------------------------------------------------------------
1 | /* Font changes */
2 | .md-typeset {
3 | font-weight: 300;
4 | }
5 |
6 | .md-typeset h1 {
7 | font-weight: 600;
8 | margin: 0;
9 | font-size: 2.5em;
10 | }
11 |
12 | .md-typeset h2 {
13 | font-weight: 500;
14 | }
15 |
16 | .md-typeset h3 {
17 | font-weight: 400;
18 | }
19 |
20 | /* Reduce size of the outdated banner */
21 | .md-banner__inner {
22 | margin: 0.45rem auto;
23 | }
24 |
25 | /* Footer styling */
26 | .md-footer {
27 | border-top: 1px solid var(--md-footer-border-color);
28 | }
29 |
30 | .md-copyright {
31 | width: 100%;
32 | }
33 |
34 | .md-copyright__highlight {
35 | width: 100%;
36 | }
37 |
38 | .legal-footer-right {
39 | float: right;
40 | }
41 |
42 | .md-copyright__highlight div {
43 | display: inline;
44 | }
45 |
--------------------------------------------------------------------------------
/docs/src/changelog.md:
--------------------------------------------------------------------------------
1 | ---
2 | hide:
3 | - toc
4 | ---
5 |
6 |
11 |
12 | {% include-markdown "../../CHANGELOG.md" %}
13 |
--------------------------------------------------------------------------------
/docs/src/contributing.md:
--------------------------------------------------------------------------------
1 | ## Creating a development environment
2 |
3 | If you plan to make code changes to this repository, you will need to install the following dependencies first:
4 |
5 | - [Git](https://git-scm.com/downloads)
6 | - [Python 3.9+](https://www.python.org/downloads/)
7 | - [Hatch](https://hatch.pypa.io/latest/)
8 |
9 | Once you finish installing these dependencies, you can clone this repository:
10 |
11 | ```shell
12 | git clone https://github.com/Archmonger/ServeStatic.git
13 | cd ServeStatic
14 | ```
15 |
16 | ## Executing test environment commands
17 |
18 | By utilizing `hatch`, the following commands are available to manage the development environment.
19 |
20 | ### Tests
21 |
22 | | Command | Description |
23 | | --- | --- |
24 | | `hatch test` | Run Python tests using the current environment's Python version |
25 | | `hatch test --all` | Run tests using all compatible Python and Django versions |
26 | | `hatch test --python 3.9` | Run tests using a specific Python version |
27 | | `hatch test --include "django=5.1"` | Run tests using a specific Django version |
28 | | `hatch test -k test_get_js_static_file` | Run only a specific test |
29 |
30 | ??? question "What other arguments are available to me?"
31 |
32 | The `hatch test` command is a wrapper for `pytest`. Hatch "intercepts" a handful of arguments, which can be previewed by typing `hatch test --help`.
33 |
34 | Any additional arguments in the `test` command are directly passed on to pytest. See the [pytest documentation](https://docs.pytest.org/en/stable/reference/reference.html#command-line-flags) for what additional arguments are available.
35 |
36 | ### Linting and Formatting
37 |
38 | | Command | Description |
39 | | --- | --- |
40 | | `hatch fmt` | Run all linters and formatters |
41 | | `hatch fmt --check` | Run all linters and formatters, but do not save fixes to the disk |
42 | | `hatch fmt --linter` | Run only linters |
43 | | `hatch fmt --formatter` | Run only formatters |
44 | | `hatch run precommit:check` | Run all [`pre-commit`](https://pre-commit.com/) checks configured within this repository |
45 | | `hatch run precommit:update` | Update the [`pre-commit`](https://pre-commit.com/) hooks configured within this repository |
46 |
47 | ??? tip "Configure your IDE for linting"
48 |
49 | This repository uses `hatch fmt` for linting and formatting, which is a [modestly customized](https://hatch.pypa.io/latest/config/internal/static-analysis/#default-settings) version of [`ruff`](https://github.com/astral-sh/ruff).
50 |
51 | You can install `ruff` as a plugin to your preferred code editor to create a similar environment.
52 |
53 | ### Documentation
54 |
55 | | Command | Description |
56 | | --- | --- |
57 | | `hatch run docs:serve` | Start the [`mkdocs`](https://www.mkdocs.org/) server to view documentation locally |
58 | | `hatch run docs:build` | Build the documentation |
59 | | `hatch run docs:linkcheck` | Check for broken links in the documentation |
60 | | `hatch run scripts\validate_changelog.py` | Check if the changelog meets the [Keep A Changelog](https://keepachangelog.com/en/1.1.0/) specification |
61 |
62 | ### Environment Management
63 |
64 | | Command | Description |
65 | | --- | --- |
66 | | `hatch build --clean` | Build the package from source |
67 | | `hatch env prune` | Delete all virtual environments created by `hatch` |
68 | | `hatch python install 3.12` | Install a specific Python version to your system |
69 |
70 | ??? tip "Check out Hatch for all available commands!"
71 |
72 | This documentation only covers commonly used commands.
73 |
74 | You can type `hatch --help` to see all available commands.
75 |
--------------------------------------------------------------------------------
/docs/src/dictionary.txt:
--------------------------------------------------------------------------------
1 | wsgi
2 | asgi
3 | gzip
4 | filesystem
5 | mimetype
6 | mimetypes
7 | django
8 | backend
9 | backends
10 | changelog
11 | rackspace
12 | staticfiles
13 | storages
14 | symlinks
15 | webpack
16 | browserify
17 | frontend
18 | brotli
19 | heroku
20 | nginx
21 | gunicorn
22 | syscall
23 | artifact
24 | charset
25 | cacheable
26 | runtime
27 | subclassing
28 | behaviors
29 | bakhit
30 | sublicense
31 | middleware
32 | unhashed
33 | async
34 | linter
35 | linters
36 | linting
37 | pytest
38 | formatters
39 |
--------------------------------------------------------------------------------
/docs/src/django-faq.md:
--------------------------------------------------------------------------------
1 | ## How to I use `ServeStatic` with Django Compressor?
2 |
3 | For performance and security reasons `ServeStatic` does not check for new 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](https://django-compressor.readthedocs.io/en/stable/usage.html#offline-compression) feature.
4 |
5 | ---
6 |
7 | ## Can I use `ServeStatic` for media files?
8 |
9 | `ServeStatic` is not suitable for serving user-uploaded "media" files. For one thing, as described above, it only checks for static files at startup and so files added after the app starts won't be seen. More importantly though, serving user-uploaded files from the same domain as your main application is a security risk (this [blog post](https://security.googleblog.com/2012/08/content-hosting-for-modern-web.html) from Google security describes the problem well). And in addition to that, using local disk to store and serve your user media makes it harder to scale your application across multiple machines.
10 |
11 | For all these reasons, it's much better to store files on a separate dedicated storage service and serve them to users from there. The [django-storages](https://django-storages.readthedocs.io/) library provides many options e.g. Amazon S3, Azure Storage, and Rackspace CloudFiles.
12 |
13 | ---
14 |
15 | ## How check if `ServeStatic` is working?
16 |
17 | You can confirm that `ServeStatic` is installed and configured correctly by running you application locally with `DEBUG` disabled and checking that your static files still load.
18 |
19 | First you need to run `collectstatic` to get your files in the right place:
20 |
21 | ```bash
22 | python manage.py collectstatic
23 | ```
24 |
25 | Then make sure `DEBUG` is set to `False` in your `settings.py` and start the server:
26 |
27 | ```bash
28 | python manage.py runserver
29 | ```
30 |
31 | You should find that your static files are served, just as they would be in production.
32 |
33 | ---
34 |
35 | ## How do I troubleshoot the `ServeStatic` storage backend?
36 |
37 | If you're having problems with the `ServeStatic` storage backend, the chances are they're due to the underlying Django storage engine. This is because `ServeStatic` only adds a thin wrapper around Django's storage to add compression support, and because the compression code is very simple it generally doesn't cause problems.
38 |
39 | The most common issue is that there are CSS files which reference other files (usually images or fonts) which don't exist at that specified path. When Django attempts to rewrite these references it looks for the corresponding file and throws an error if it can't find it.
40 |
41 | To test whether the problems are due to `ServeStatic` or not, try swapping the `ServeStatic` storage backend for the Django one:
42 |
43 | ```python
44 | STORAGES = {
45 | "staticfiles": {
46 | "BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage",
47 | },
48 | }
49 | ```
50 |
51 | If the problems persist then your issue is with Django itself (try the [docs](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/) or the [mailing list](https://groups.google.com/d/forum/django-users)). If the problem only occurs with ServeStatic then raise a ticket on the [issue tracker](https://github.com/Archmonger/ServeStatic/issues).
52 |
53 | ---
54 |
55 | ## Can I use `ServeStatic` with other storage backends?
56 |
57 | `ServeStatic` will only work with storage backends that stores their files on the local filesystem in `STATIC_ROOT`. It will not work with backends that store files remotely, for instance on Amazon S3.
58 |
59 | ## Why does `ServeStatic` make my tests run slow?
60 |
61 | `ServeStatic` is designed to do as much work as possible upfront when the application starts so that it can serve files as efficiently as possible while the application is running. This makes sense for long-running production processes, but you might find that the added startup time is a problem during test runs when application instances are frequently being created and destroyed.
62 |
63 | The simplest way to fix this is to make sure that during testing the `SERVESTATIC_AUTOREFRESH` setting is set to `True`. (By default it is `True` when `DEBUG` is enabled and `False` otherwise.) This stops `ServeStatic` from scanning your static files on start up but other than that its behaviour should be exactly the same.
64 |
65 | It is also worth making sure you don't have unnecessary files in your `STATIC_ROOT` directory. In particular, be careful not to include a `node_modules` directory which can contain a very large number of files and significantly slow down your application startup. If you need to include specific files from `node_modules` then you can create symlinks from within your static directory to just the files you need.
66 |
67 | ## Why do I get "ValueError: Missing staticfiles manifest entry for ..."?
68 |
69 | 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
70 |
71 | ```sh
72 | python manage.py findstatic --verbosity 2 foo
73 | ```
74 |
75 | which will show you all the paths which Django searches for the file "foo".
76 |
77 | If, for some reason, you want Django to silently ignore such errors you can set `SERVESTATIC_MANIFEST_STRICT` to `False`.
78 |
79 | ## How do I use `ServeStatic` with Webpack/Browserify/etc?
80 |
81 | A simple technique for integrating any frontend build system with Django is to use a directory layout like this:
82 |
83 | ```
84 | ./static_src
85 | ↓
86 | $ ./node_modules/.bin/webpack
87 | ↓
88 | ./static_build
89 | ↓
90 | $ ./manage.py collectstatic
91 | ↓
92 | ./static_root
93 | ```
94 |
95 | Here `static_src` contains all the source files (JS, CSS, etc) for your project. Your build tool (which can be Webpack, Browserify or whatever you choose) then processes these files and writes the output into `static_build`.
96 |
97 | The path to the `static_build` directory is added to `settings.py`:
98 |
99 | ```python
100 | STATICFILES_DIRS = [BASE_DIR / "static_build"]
101 | ```
102 |
103 | This means that Django can find the processed files, but doesn't need to know anything about the tool which produced them.
104 |
105 | The final `manage.py collectstatic` step writes "hash-versioned" and compressed copies of the static files into `static_root` ready for production.
106 |
107 | Note, both the `static_build` and `static_root` directories should be excluded from version control (e.g. through `.gitignore`) and only the `static_src` directory should be checked in.
108 |
109 | ## How do I deploy an application which is not at the root of the domain?
110 |
111 | Sometimes Django apps are deployed at a particular prefix (or "subdirectory") on a domain e.g. `https://example.com/my-app/` rather than just `https://example.com`.
112 |
113 | In this case you would normally use Django's [FORCE_SCRIPT_NAME](https://docs.djangoproject.com/en/stable/ref/settings/#force-script-name) setting to tell the application where it is located. You would also need to ensure that `STATIC_URL` uses the correct prefix as well. For example:
114 |
115 | ```python
116 | FORCE_SCRIPT_NAME = "/my-app"
117 | STATIC_URL = FORCE_SCRIPT_NAME + "/static/"
118 | ```
119 |
120 | If you have set these two values then `ServeStatic` will automatically configure itself correctly. If you are doing something more complex you may need to set `SERVESTATIC_STATIC_PREFIX` explicitly yourself.
121 |
--------------------------------------------------------------------------------
/docs/src/django-settings.md:
--------------------------------------------------------------------------------
1 | !!! Note
2 |
3 | The `ServeStaticMiddleware` class can take the same configuration options as the `ServeStatic` base class, but rather than accepting keyword arguments to its constructor it uses Django settings. The setting names are just the keyword arguments upper-cased with a `SERVESTATIC_` prefix.
4 |
5 | ---
6 |
7 | ## `SERVESTATIC_ROOT`
8 |
9 | **Default:** `None`
10 |
11 | Absolute path to a directory of files which will be served at the root of your application (ignored if not set).
12 |
13 | Don't use this for the bulk of your static files because you won't benefit from cache versioning, but it can be convenient for files like `robots.txt` or `favicon.ico` which you want to serve at a specific URL.
14 |
15 | ---
16 |
17 | ## `SERVESTATIC_AUTOREFRESH`
18 |
19 | **Default:** `settings.py:DEBUG`
20 |
21 | Recheck the filesystem to see if any files have changed before responding. This is designed to be used in development where it can be convenient to pick up changes to static files without restarting the server. For both performance and security reasons, this setting should not be used in production.
22 |
23 | ---
24 |
25 | ## `SERVESTATIC_USE_MANIFEST`
26 |
27 | **Default:** `not settings.py:DEBUG and isinstance(staticfiles_storage, ManifestStaticFilesStorage)`
28 |
29 | Find and serve files using Django's manifest file.
30 |
31 | This is the most efficient way to determine what files are available, but it requires that you are using a [manifest-compatible](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#manifeststaticfilesstorage) storage backend.
32 |
33 | When using ServeStatic's [`CompressedManifestStaticFilesStorage`](./django.md#step-2-add-compression-and-caching-support) storage backend, ServeStatic will no longer need to call `os.stat` on each file during startup.
34 |
35 | ---
36 |
37 | ## `SERVESTATIC_USE_FINDERS`
38 |
39 | **Default:** `settings.py:DEBUG`
40 |
41 | Find and serve files using Django's [`finders`](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#finders-module) API.
42 |
43 | It's possible to use this setting in production, but be mindful of the [`settings.py:STATICFILES_DIRS`](https://docs.djangoproject.com/en/stable/ref/settings/#staticfiles-dirs) and [`settings.py:STATICFILE_FINDERS`](https://docs.djangoproject.com/en/stable/ref/settings/#staticfiles-finders) settings. By default, the finders API only searches the `'static'` directory in each app, which are not the copies post-processed by ServeStatic.
44 |
45 | Note that `STATICFILES_DIRS` cannot equal `STATIC_ROOT` while running the `collectstatic` management command.
46 |
47 | ---
48 |
49 | ## `SERVESTATIC_MAX_AGE`
50 |
51 | **Default:** `60 if not settings.py:DEBUG else 0`
52 |
53 | Time (in seconds) for which browsers and proxies should cache **non-versioned** files.
54 |
55 | 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.
56 |
57 | The default is chosen to be short enough not to cause problems with stale versions but long enough that, if you're running `ServeStatic` behind a CDN, the CDN will still take the majority of the strain during times of heavy load.
58 |
59 | Set to `None` to disable setting any `Cache-Control` header on non-versioned files.
60 |
61 | ---
62 |
63 | ## `SERVESTATIC_INDEX_FILE`
64 |
65 | **Default:** `False`
66 |
67 | If `True` enable index file serving. If set to a non-empty string, enable index files and use that string as the index file name.
68 |
69 | ---
70 |
71 | ## `SERVESTATIC_MIMETYPES`
72 |
73 | **Default:** `None`
74 |
75 | A dictionary mapping file extensions (lowercase) to the mimetype for that extension. For example: :
76 |
77 | ```json linenums="0"
78 | { ".foo": "application/x-foo" }
79 | ```
80 |
81 | Note that `ServeStatic` 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 ServeStatic's `media_types.py` file.
82 |
83 | In addition to file extensions, mimetypes can be specified by supplying the entire filename, for example: :
84 |
85 | ```json linenums="0"
86 | { "some-special-file": "application/x-custom-type" }
87 | ```
88 |
89 | ---
90 |
91 | ## `SERVESTATIC_CHARSET`
92 |
93 | **Default:** `#!python 'utf-8'`
94 |
95 | Charset to add as part of the `Content-Type` header for all files whose mimetype allows a charset.
96 |
97 | ---
98 |
99 | ## `SERVESTATIC_ALLOW_ALL_ORIGINS`
100 |
101 | **Default:** `True`
102 |
103 | Toggles whether to send an `Access-Control-Allow-Origin: *` header for all static files.
104 |
105 | This allows cross-origin requests for static files which means your static files will continue to work as expected even if they are served via a CDN and therefore on a different domain. Without this your static files will _mostly_ work, but you may have problems with fonts loading in Firefox, or accessing images in canvas elements, or other mysterious things.
106 |
107 | The W3C [explicitly state](https://www.w3.org/TR/cors/#security) that this behaviour is safe for publicly accessible files.
108 |
109 | ---
110 |
111 | ## `SERVESTATIC_SKIP_COMPRESS_EXTENSIONS`
112 |
113 | **Default:** `('jpg', 'jpeg', 'png', 'gif', 'webp','zip', 'gz', 'tgz', 'bz2', 'tbz', 'xz', 'br', 'swf', 'flv', 'woff', 'woff2')`
114 |
115 | File extensions to skip when compressing.
116 |
117 | Because the compression process will only create compressed files where this results in an actual size saving, it would be safe to leave this list empty and attempt to compress all files. However, for files which we're confident won't benefit from compression, it speeds up the process if we just skip over them.
118 |
119 | ---
120 |
121 | ## `SERVESTATIC_ADD_HEADERS_FUNCTION`
122 |
123 | **Default:** `None`
124 |
125 | Reference to a function which is passed the headers object for each static file, allowing it to modify them.
126 |
127 | The function should not return anything; changes should be made by modifying the headers dictionary directly.
128 |
129 | For example:
130 |
131 | ```python
132 | def force_download_pdfs(headers, path, url):
133 | """
134 | Args:
135 | headers: A [wsgiref.headers](https://docs.python.org/3/library/wsgiref.html#module-wsgiref.headers)\
136 | instance (which you can treat just as a dict) containing the headers for the current file
137 | path: The absolute path to the local file
138 | url: The host-relative URL of the file e.g. `/static/styles/app.css`
139 |
140 | """
141 | if path.endswith(".pdf"):
142 | headers["Content-Disposition"] = "attachment"
143 |
144 |
145 | SERVESTATIC_ADD_HEADERS_FUNCTION = force_download_pdfs
146 | ```
147 |
148 | ---
149 |
150 | ## `SERVESTATIC_IMMUTABLE_FILE_TEST`
151 |
152 | **Default:** See [`immutable_file_test`](./servestatic.md#immutable_file_test) in source
153 |
154 | Reference to function, or string.
155 |
156 | If a reference to a function, this is passed the path and URL for each static file and should return whether that file is immutable, i.e. guaranteed not to change, and so can be safely cached forever. The default is designed to work with Django's `ManifestStaticFilesStorage` backend, and any derivatives of that, so you should only need to change this if you are using a different system for versioning your static files.
157 |
158 | If a string, this is treated as a regular expression and each file's URL is matched against it.
159 |
160 | Example:
161 |
162 | ```python
163 | def immutable_file_test(path, url):
164 | """
165 | Args:
166 | path: The absolute path to the local file
167 | url: The host-relative URL of the file e.g. `/static/styles/app.css`
168 | """
169 | # Match filename with 12 hex digits before the extension
170 | # e.g. app.db8f2edc0c8a.js
171 | return re.match(r"^.+\.[0-9a-f]{12}\..+$", url)
172 |
173 |
174 | SERVESTATIC_IMMUTABLE_FILE_TEST = immutable_file_test
175 | ```
176 |
177 | ---
178 |
179 | ## `SERVESTATIC_STATIC_PREFIX`
180 |
181 | **Default:** `settings.py:STATIC_URL`
182 |
183 | The URL prefix under which static files will be served.
184 |
185 | If this setting is unset, this value will automatically determined by analysing your `STATIC_URL` setting. For example, if `STATIC_URL = 'https://example.com/static/'` then `SERVESTATIC_STATIC_PREFIX` will be `/static/`.
186 |
187 | Note that `FORCE_SCRIPT_NAME` is also taken into account when automatically determining this value. For example, if `FORCE_SCRIPT_NAME = 'subdir/'` and `STATIC_URL = 'subdir/static/'` then `SERVESTATIC_STATIC_PREFIX` will be `/static/`.
188 |
189 | If your deployment is more complicated than this (for instance, if you are using a CDN which is doing [path rewriting](https://blog.nginx.org/blog/creating-nginx-rewrite-rules)) then you may need to configure this value directly.
190 |
191 | ---
192 |
193 | ## `SERVESTATIC_KEEP_ONLY_HASHED_FILES`
194 |
195 | **Default:** `False`
196 |
197 | Stores only files with hashed names in `STATIC_ROOT`.
198 |
199 | By default, Django's hashed static files system creates two copies of each file in `STATIC_ROOT`: one using the original name, e.g. `app.js`, and one using the hashed name, e.g. `app.db8f2edc0c8a.js`. If `ServeStatic`'s compression backend is being used this will create another two copies of each of these files (using Gzip and Brotli compression) resulting in six output files for each input file.
200 |
201 | In some deployment scenarios it can be important to reduce the size of the build artifact as much as possible. This setting removes the "unhashed" version of the file (which should be not be referenced in any case) which should reduce the space required for static files by half.
202 |
203 | This setting is only effective if the `ServeStatic` storage backend is being used.
204 |
205 | ---
206 |
207 | ## `SERVESTATIC_MANIFEST_STRICT`
208 |
209 | **Default:** `True`
210 |
211 | Set to `False` to prevent Django throwing an error if you reference a static file which doesn't exist in the manifest.
212 |
213 | This works by setting the [`manifest_strict`](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#django.contrib.staticfiles.storage.ManifestStaticFilesStorage.manifest_strict) option on the underlying Django storage instance, as described in the Django documentation.
214 |
215 | This setting is only effective if the `ServeStatic` storage backend is being used.
216 |
217 | !!! Note
218 |
219 | If a file isn't found in the `staticfiles.json` manifest at runtime, a `ValueError` is raised. This behavior can be disabled by subclassing `ManifestStaticFilesStorage` and setting the `manifest_strict` attribute to `False` -- nonexistent paths will remain unchanged.
220 |
--------------------------------------------------------------------------------
/docs/src/django.md:
--------------------------------------------------------------------------------
1 | # Using ServeStatic with Django
2 |
3 | This guide walks you through setting up a Django project with ServeStatic. In most cases it shouldn't take more than a couple of lines of configuration.
4 |
5 | We mention Heroku in a few places, but there's nothing Heroku-specific about ServeStatic and the instructions below should apply whatever your hosting platform.
6 |
7 | ## Step 1: Enable ServeStatic
8 |
9 | Edit your `settings.py` file and add ServeStatic to the `MIDDLEWARE` list.
10 |
11 | !!! warning "Middleware order is important!"
12 |
13 | The ServeStatic middleware should be placed directly after the Django [SecurityMiddleware](https://docs.djangoproject.com/en/stable/ref/middleware/#module-django.middleware.security) (if you are using it) and before all other middleware.
14 |
15 | ```python linenums="0"
16 | MIDDLEWARE = [
17 | "django.middleware.security.SecurityMiddleware",
18 | "servestatic.middleware.ServeStaticMiddleware",
19 | # ...
20 | ]
21 | ```
22 |
23 | That's it! ServeStatic is now configured to serve your static files. For optimal performance, proceed to the next step to enable compression and caching.
24 |
25 | ??? question "How should I order my middleware?"
26 |
27 | You might find other third-party middleware that suggests it should be given highest priority at the top of the middleware list. Unless you understand exactly what is happening you should always place `ServeStaticMiddleware` above all middleware other than `SecurityMiddleware`.
28 |
29 | ## Step 2: Add compression and caching support
30 |
31 | ServeStatic 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.
32 |
33 | ```python linenums="0"
34 | STORAGES = {
35 | # ...
36 | "staticfiles": {
37 | "BACKEND": "servestatic.storage.CompressedManifestStaticFilesStorage",
38 | },
39 | }
40 | ```
41 |
42 | This combines automatic compression with the caching behaviour provided by Django's [ManifestStaticFilesStorage](https://docs.djangoproject.com/en/stable/ref/contrib/staticfiles/#manifeststaticfilesstorage) backend. If you want to apply compression but don't want the caching behaviour then you can use the alternative backend:
43 |
44 | ```python linenums="0"
45 | "servestatic.storage.CompressedStaticFilesStorage"
46 | ```
47 |
48 | If you need to compress files outside of the static files storage system you can use the supplied [command line utility](servestatic.md#compression-support).
49 |
50 | ??? tip "Enable Brotli compression"
51 |
52 | As well as the common gzip compression format, ServeStatic supports the newer, more efficient [brotli](https://en.wikipedia.org/wiki/Brotli)
53 | format. This helps reduce bandwidth and increase loading speed. To enable brotli compression you will need the [Brotli Python
54 | package](https://pypi.org/project/Brotli/) installed by running `pip install servestatic[brotli]`.
55 |
56 | Brotli is supported by [all modern browsers](https://caniuse.com/#feat=brotli). ServeStatic will only serve brotli data to browsers which request it so there are no compatibility issues with enabling brotli support.
57 |
58 | Also note that browsers will only request brotli data over an HTTPS connection.
59 |
60 | ## Step 3: Make sure Django's `staticfiles` is configured correctly
61 |
62 | If you're familiar with Django you'll know what to do. If you're just getting started with a new Django project then you'll need add the following to the bottom of your `settings.py` file:
63 |
64 | ```python linenums="0"
65 | STATIC_ROOT = BASE_DIR / "staticfiles"
66 | ```
67 |
68 | As part of deploying your application you'll need to run `./manage.py collectstatic` to put all your static files into `STATIC_ROOT`. (If you're running on Heroku then this is done automatically for you.)
69 |
70 | Make sure you're using the [static](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#std:templatetag-static) template tag to refer to your static files, rather than writing the URL directly. For example:
71 |
72 | ```django linenums="0"
73 | {% load static %}
74 |
75 |
76 |
77 |
78 | ```
79 |
80 | For further details see the Django [staticfiles](https://docs.djangoproject.com/en/stable/howto/static-files/) guide.
81 |
82 | ---
83 |
84 | ## Step 4: Optional configuration
85 |
86 | ### Modify ServeStatic's Django settings
87 |
88 | ServeStatic has a number of configuration options that you can set in your `settings.py` file.
89 |
90 | See the [reference documentation](./django-settings.md) for a full list of options.
91 |
92 | ### Enable ServeStatic during development
93 |
94 | In development Django's `runserver` automatically takes over static file handling. In most cases this is fine, however this means that some of the improvements that ServeStatic makes to static file handling won't be available in development and it opens up the possibility for differences in behaviour between development and production environments. For this reason it's a good idea to use ServeStatic in development as well.
95 |
96 | You can disable Django's static file handling and allow ServeStatic to take over simply by passing the `--nostatic` option to the `runserver` command, but you need to remember to add this option every time you call `runserver`. An easier way is to edit your `settings.py` file and add `servestatic.runserver_nostatic` to the top of your `INSTALLED_APPS` list:
97 |
98 | ```python linenums="0"
99 | INSTALLED_APPS = [
100 | "servestatic.runserver_nostatic",
101 | "django.contrib.staticfiles",
102 | # ...
103 | ]
104 | ```
105 |
106 | ### Utilize a Content Delivery Network (CDN)
107 |
108 |
109 |
110 | The above steps will get you decent performance on moderate traffic sites, however for higher traffic sites, or sites where performance is a concern you should look at using a CDN.
111 |
112 | Because ServeStatic sends appropriate cache headers with your static content, the CDN will be able to cache your files and serve them without needing to contact your application again.
113 |
114 | Below are instruction for setting up ServeStatic with Amazon CloudFront, a popular choice of CDN. The process for other CDNs should look very similar though.
115 |
116 | ??? abstract "Configuring Amazon CloudFront"
117 |
118 | Go to CloudFront section of the AWS Web Console, and click "Create Distribution". Put your application's domain (without the `http` prefix) in the "Origin Domain Name" field and leave the rest of the settings as they are.
119 |
120 | It might take a few minutes for your distribution to become active. Once it's ready, copy the distribution domain name into your `settings.py` file so it looks something like this:
121 |
122 | ```python linenums="0"
123 | STATIC_HOST = "https://d4663kmspf1sqa.cloudfront.net" if not DEBUG else ""
124 | STATIC_URL = STATIC_HOST + "/static/"
125 | ```
126 |
127 | Or, even better, you can avoid hard-coding your CDN into your settings by
128 | doing something like this:
129 |
130 | ```python linenums="0"
131 | STATIC_HOST = os.environ.get("DJANGO_STATIC_HOST", "")
132 | STATIC_URL = STATIC_HOST + "/static/"
133 | ```
134 |
135 | This way you can configure your CDN just by setting an environment
136 | variable. For apps on Heroku, you'd run this command
137 |
138 | ```bash linenums="0"
139 | heroku config:set DJANGO_STATIC_HOST=https://d4663kmspf1sqa.cloudfront.net
140 | ```
141 |
142 | ??? abstract "CloudFront compression algorithms"
143 |
144 | By default, CloudFront will discard any `Accept-Encoding` header browsers include in requests, unless the value of the header is gzip. If it is gzip, CloudFront will fetch the uncompressed file from the origin, compress it, and return it to the requesting browser.
145 |
146 | To get CloudFront to not do the compression itself as well as serve files compressed using other algorithms, such as Brotli, you must configure your distribution to [cache based on the Accept-Encoding header](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/ServingCompressedFiles.html#compressed-content-custom-origin). You can do this in the `Behaviours` tab of your distribution.
147 |
148 | ??? warning "CloudFront SEO issues"
149 |
150 | The instructions for setting up CloudFront given above will result in the entire site being accessible via the CloudFront URL. It's possible that this can cause SEO problems if these URLs start showing up in search results. You can restrict CloudFront to only proxy your static files by following these directions:
151 |
152 | 1. Go to your newly created distribution and click "_Distribution Settings_", then the "_Behaviors_" tab, then "_Create Behavior_". Put `static/*` into the path pattern and click "_Create_" to save.
153 | 2. Now select the `Default (*)` behaviour and click "_Edit_". Set "_Restrict Viewer Access_" to "_Yes_" and then click "_Yes, Edit_" to save.
154 | 3. Check that the `static/*` pattern is first on the list, and the default one is second. This will ensure that requests for static files are passed through but all others are blocked.
155 |
156 |
157 |
--------------------------------------------------------------------------------
/docs/src/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | hide:
3 | - navigation
4 | ---
5 |
6 | {% include-markdown "../../README.md" start="" end="" %}
7 |
--------------------------------------------------------------------------------
/docs/src/license.md:
--------------------------------------------------------------------------------
1 | ---
2 | hide:
3 | - toc
4 | ---
5 |
6 |
11 |
12 | {% include-markdown "../../LICENSE.md" %}
13 |
--------------------------------------------------------------------------------
/docs/src/quick-start.md:
--------------------------------------------------------------------------------
1 | The documentation below is a quick-start guide to using ServeStatic to serve your static files. For more detailed information see the [full installation docs](django.md).
2 |
3 | ---
4 |
5 | ## Installation
6 |
7 | Install with:
8 |
9 | ```bash linenums="0"
10 | pip install servestatic
11 | ```
12 |
13 | ## Using with Django
14 |
15 | Edit your `settings.py` file and add ServeStatic to the `MIDDLEWARE` list, above all other middleware apart from Django's [SecurityMiddleware](https://docs.djangoproject.com/en/stable/ref/middleware/#module-django.middleware.security).
16 |
17 | ```python linenums="0"
18 | MIDDLEWARE = [
19 | "django.middleware.security.SecurityMiddleware",
20 | "servestatic.middleware.ServeStaticMiddleware",
21 | # ...
22 | ]
23 | ```
24 |
25 | That's it, you're ready to go.
26 |
27 | Want forever-cacheable files and compression support? Just add this to your `settings.py`.
28 |
29 | ```python linenums="0"
30 | STORAGES = {
31 | "staticfiles": {
32 | "BACKEND": "servestatic.storage.CompressedManifestStaticFilesStorage",
33 | },
34 | }
35 | ```
36 |
37 | For more details, including on setting up CloudFront and other CDNs see the [full Django guide](django.md).
38 |
39 | ## Using with WSGI
40 |
41 | To enable `ServeStatic` you need to wrap your existing WSGI application in a `ServeStatic` instance and tell it where to find your static files. For example...
42 |
43 | ```python
44 | from servestatic import ServeStatic
45 |
46 | from my_project import MyWSGIApp
47 |
48 | application = MyWSGIApp()
49 | application = ServeStatic(application, root="/path/to/static/files")
50 | application.add_files("/path/to/more/static/files", prefix="more-files/")
51 | ```
52 |
53 | Alternatively, you can use ServeStatic as a standalone file server by not providing a WSGI app, such as via `#!python ServeStatic(None, root="/path/to/static/files")`.
54 |
55 | And that's it, you're ready to go. For more details see the [full WSGI guide](wsgi.md).
56 |
57 | ## Using with ASGI
58 |
59 | To enable `ServeStatic` you need to wrap your existing ASGI application in a `ServeStatic` instance and tell it where to find your static files. For example...
60 |
61 | ```python
62 | from servestatic import ServeStaticASGI
63 |
64 | from my_project import MyASGIApp
65 |
66 | application = MyASGIApp()
67 | application = ServeStaticASGI(application, root="/path/to/static/files")
68 | application.add_files("/path/to/more/static/files", prefix="more-files/")
69 | ```
70 |
71 | Alternatively, you can use ServeStatic as a standalone file server by not providing a ASGI app, such as via `#!python ServeStaticASGI(None, root="/path/to/static/files")`.
72 |
73 | And that's it, you're ready to go. For more details see the [full ASGI guide](asgi.md).
74 |
--------------------------------------------------------------------------------
/docs/src/servestatic-asgi.md:
--------------------------------------------------------------------------------
1 | # `ServeStaticASGI` API Reference
2 |
3 | !!! tip
4 |
5 | `ServeStaticASGI` inherits its interface and features from the [WSGI variant](servestatic.md).
6 |
7 | | Name | Type | Description | Default |
8 | | --- | --- | --- | --- |
9 | | `application` | `Union[Callable, None]` | Your ASGI application. If set to `None`, then ServeStatic will run in standalone mode. | N/A |
10 | | `root` | `str` | Absolute path to a directory of static files to be served. | `None` |
11 | | `prefix` | `str` | If set, the URL prefix under which the files will be served. Trailing slashes are automatically added. | `None` |
12 | | `**kwargs` | | Sets [configuration attributes](#configuration-attributes) for this instance | N/A |
13 |
14 | {% include-markdown "./servestatic.md" start="" end="" rewrite-relative-urls=false %}
15 |
--------------------------------------------------------------------------------
/docs/src/servestatic.md:
--------------------------------------------------------------------------------
1 | # `ServeStatic` API Reference
2 |
3 | | Name | Type | Description | Default |
4 | | --- | --- | --- | --- |
5 | | `application` | `Union[Callable, None]` | Your WSGI application. If set to `None`, then ServeStatic will run in standalone mode. | N/A |
6 | | `root` | `str` | Absolute path to a directory of static files to be served. | `None` |
7 | | `prefix` | `str` | If set, the URL prefix under which the files will be served. Trailing slashes are automatically added. | `None` |
8 | | `**kwargs` | | Sets [configuration attributes](#configuration-attributes) for this instance | N/A |
9 |
10 |
11 |
12 | ## Configuration attributes
13 |
14 | These can be set by passing keyword arguments to the constructor, or by sub-classing ServeStatic and setting the attributes directly.
15 |
16 | ---
17 |
18 | ### `autorefresh`
19 |
20 | **Default:** `False`
21 |
22 | Recheck the filesystem to see if any files have changed before responding. This is designed to be used in development where it can be convenient to pick up changes to static files without restarting the server. For both performance and security reasons, this setting should not be used in production.
23 |
24 | ---
25 |
26 | ### `max_age`
27 |
28 | **Default:** `60`
29 |
30 | Time (in seconds) for which browsers and proxies should cache files.
31 |
32 | The default is chosen to be short enough not to cause problems with stale versions but long enough that, if you're running ServeStatic behind a CDN, the CDN will still take the majority of the strain during times of heavy load.
33 |
34 | Set to `None` to disable setting any `Cache-Control` header on non-versioned files.
35 |
36 | ---
37 |
38 | ### `index_file`
39 |
40 | **Default:** `False`
41 |
42 | If `True` enable index file serving. If set to a non-empty string, enable index files and use that string as the index file name.
43 |
44 | When the `index_file` option is enabled:
45 |
46 | - Visiting `/example/` will serve the file at `/example/index.html`
47 | - Visiting `/example` will redirect (302) to `/example/`
48 | - Visiting `/example/index.html` will redirect (302) to `/example/`
49 |
50 | If you want to something other than `index.html` as the index file, then you can also set this option to an alternative filename.
51 |
52 | ---
53 |
54 | ### `mimetypes`
55 |
56 | **Default:** `None`
57 |
58 | A dictionary mapping file extensions (lowercase) to the mimetype for that extension. For example:
59 |
60 | ```python linenums="0"
61 | {".foo": "application/x-foo"}
62 | ```
63 |
64 | Note that ServeStatic 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 `media_types.py` file.
65 |
66 | In addition to file extensions, mimetypes can be specified by supplying the entire filename, for example:
67 |
68 | ```json linenums="0"
69 | { "some-special-file": "application/x-custom-type" }
70 | ```
71 |
72 | ---
73 |
74 | ### `charset`
75 |
76 | **Default:** `utf-8`
77 |
78 | Charset to add as part of the `Content-Type` header for all files whose mimetype allows a charset.
79 |
80 | ---
81 |
82 | ### `allow_all_origins`
83 |
84 | **Default:** `True`
85 |
86 | Toggles whether to send an `Access-Control-Allow-Origin: *` header for all static files.
87 |
88 | This allows cross-origin requests for static files which means your static files will continue to work as expected even if they are served via a CDN and therefore on a different domain. Without this your static files will _mostly_ work, but you may have problems with fonts loading in Firefox, or accessing images in canvas elements, or other mysterious things.
89 |
90 | The W3C [explicitly state](https://www.w3.org/TR/cors/#security) that this behaviour is safe for publicly accessible files.
91 |
92 | ---
93 |
94 | ### `add_headers_function`
95 |
96 | **Default:** `None`
97 |
98 | Reference to a function which is passed the headers object for each static file, allowing it to modify them.
99 |
100 | For example...
101 |
102 | ```python
103 | def force_download_pdfs(headers, path, url):
104 | """
105 | Args:
106 | headers: A wsgiref.headers instance (which you can treat \
107 | just as a dict) containing the headers for the current \
108 | file
109 | path: The absolute path to the local file
110 | url: The host-relative URL of the file e.g. \
111 | `/static/styles/app.css`
112 |
113 | Returns:
114 | None. Changes should be made by modifying the headers \
115 | dictionary directly.
116 | """
117 | if path.endswith(".pdf"):
118 | headers["Content-Disposition"] = "attachment"
119 |
120 |
121 | application = ServeStatic(
122 | application,
123 | add_headers_function=force_download_pdfs,
124 | )
125 | ```
126 |
127 | ---
128 |
129 | ### `immutable_file_test`
130 |
131 | **Default:** `return False`
132 |
133 | Reference to function, or string.
134 |
135 | If a reference to a function, this is passed the path and URL for each static file and should return whether that file is immutable, i.e. guaranteed not to change, and so can be safely cached forever.
136 |
137 | If a string, this is treated as a regular expression and each file's URL is matched against it.
138 |
139 | For example...
140 |
141 | ```python
142 | def immutable_file_test(path, url):
143 | """
144 | Args:
145 | path: The absolute path to the local file.
146 | url: The host-relative URL of the file e.g. \
147 | `/static/styles/app.css`
148 |
149 | Returns:
150 | bool. Whether the file is immutable.
151 |
152 | """
153 | # Match filename with 12 hex digits before the extension
154 | # e.g. app.db8f2edc0c8a.js
155 | return re.match(r"^.+\.[0-9a-f]{12}\..+$", url)
156 | ```
157 |
158 | ## Compression Support
159 |
160 | When ServeStatic builds its list of available files it checks for corresponding files with a `.gz` and a `.br` suffix (e.g., `scripts/app.js`, `scripts/app.js.gz` and `scripts/app.js.br`). If it finds them, it will assume that they are (respectively) gzip and [brotli](https://en.wikipedia.org/wiki/Brotli) compressed versions of the original file and it will serve them in preference to the uncompressed version where clients indicate that they that compression format (see note on Amazon S3 for why this behaviour is important).
161 |
162 | ServeStatic comes with a command line utility which will generate compressed versions of your files for you. Note that in order for brotli compression to work the [Brotli Python package](https://pypi.org/project/Brotli/) must be installed.
163 |
164 | Usage is simple:
165 |
166 | ```console linenums="0"
167 | $ python -m servestatic.compress --help
168 | usage: compress.py [-h] [-q] [--no-gzip] [--no-brotli]
169 | root [extensions [extensions ...]]
170 |
171 | Search for all files inside *not* matching and produce
172 | compressed versions with '.gz' and '.br' suffixes (as long as this results in
173 | a smaller file)
174 |
175 | positional arguments:
176 | root Path root from which to search for files
177 | extensions File extensions to exclude from compression (default: jpg,
178 | jpeg, png, gif, webp, zip, gz, tgz, bz2, tbz, xz, br, swf, flv,
179 | woff, woff2)
180 |
181 | optional arguments:
182 | -h, --help show this help message and exit
183 | -q, --quiet Don't produce log output
184 | --no-gzip Don't produce gzip '.gz' files
185 | --no-brotli Don't produce brotli '.br' files
186 | ```
187 |
188 | You can either run this during development and commit your compressed files to your repository, or you can run this as part of your build and deploy processes. (Note that this is handled automatically in Django if you're using the custom storage backend.)
189 |
190 | ## Caching Headers
191 |
192 | By default, ServeStatic sets a max-age header on all responses it sends. You can configure this by passing a [`max_age`](#max_age) keyword argument.
193 |
194 | ServeStatic sets both `Last-Modified` and `ETag` headers for all files and will return Not Modified responses where appropriate. The ETag header uses the same format as nginx which is based on the size and last-modified time of the file. If you want to use a different scheme for generating ETags you can set them via you own function by using the [`add_headers_function`](#add_headers_function) option.
195 |
196 | Most modern static asset build systems create uniquely named versions of each file. This results in files which are immutable (i.e., they can never change their contents) and can therefore by cached indefinitely. In order to take advantage of this, ServeStatic needs to know which files are immutable. This can be done using the [`immutable_file_test`](#immutable_file_test) option which accepts a reference to a function.
197 |
198 | The exact details of how you implement this method will depend on your particular asset build system but see the [documentation](#immutable_file_test) documentation for a simple example.
199 |
200 | Once you have implemented this, any files which are flagged as immutable will have "cache forever" headers set.
201 |
202 | ## Using a Content Distribution Network
203 |
204 | {% include-markdown "./django.md" start="" end="" %}
205 |
206 |
207 |
--------------------------------------------------------------------------------
/docs/src/wsgi.md:
--------------------------------------------------------------------------------
1 | # Using ServeStatic with WSGI apps
2 |
3 | To enable ServeStatic you need to wrap your existing WSGI application in a `ServeStatic` instance and tell it where to find your static files. For example:
4 |
5 | ```python
6 | from servestatic import ServeStatic
7 |
8 | from my_project import MyWSGIApp
9 |
10 | application = MyWSGIApp()
11 | application = ServeStatic(application, root="/path/to/static/files")
12 | application.add_files("/path/to/more/static/files", prefix="more-files/")
13 | ```
14 |
15 | If you would rather use ServeStatic as a standalone file server, you can simply not provide a WSGI app, such as via `#!python ServeStatic(None, root="/path/to/static/files")`.
16 |
17 |
18 |
19 | On initialization, ServeStatic walks over all the files in the directories that have been added (descending into sub-directories) and builds a list of available static files. Any requests which match a static file get served by ServeStatic, all others are passed through to the original application.
20 |
21 |
22 |
23 | After configuring ServeStatic, you can use your favourite WSGI server (such as [`gunicorn`](https://gunicorn.org/), [`waitress`](https://pypi.org/project/waitress/), or [`nginx-unit`](https://unit.nginx.org/)) to run your application.
24 |
25 | See the [API reference documentation](servestatic.md) for detailed usage and features.
26 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | build-backend = "hatchling.build"
3 | requires = ["hatchling"]
4 |
5 | [project]
6 | name = "servestatic"
7 | description = "Production-grade static file server for Python WSGI & ASGI."
8 | readme = "README.md"
9 | keywords = ["asgi", "django", "http", "server", "static", "staticfiles", "wsgi"]
10 | license = "MIT"
11 | authors = [{ name = "Mark Bakhit", email = "archiethemonger@gmail.com" }]
12 | requires-python = ">=3.9"
13 | classifiers = [
14 | "Development Status :: 5 - Production/Stable",
15 | "Framework :: Django",
16 | "Intended Audience :: Developers",
17 | "License :: OSI Approved :: MIT License",
18 | "Operating System :: OS Independent",
19 | "Programming Language :: Python :: 3 :: Only",
20 | "Programming Language :: Python :: 3.9",
21 | "Programming Language :: Python :: 3.10",
22 | "Programming Language :: Python :: 3.11",
23 | "Programming Language :: Python :: 3.12",
24 | "Programming Language :: Python :: 3.13",
25 | "Topic :: Communications :: File Sharing",
26 | "Topic :: Internet :: WWW/HTTP",
27 | "Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware",
28 | ]
29 | dependencies = ["asgiref"]
30 | dynamic = ["version"]
31 | optional-dependencies.brotli = ["brotli"]
32 | urls.Changelog = "https://archmonger.github.io/ServeStatic/latest/changelog/"
33 | urls.Documentation = "https://archmonger.github.io/ServeStatic/"
34 | urls.Source = "https://github.com/Archmonger/ServeStatic"
35 |
36 | [tool.hatch.version]
37 | path = "src/servestatic/__init__.py"
38 |
39 | [tool.hatch.build.targets.sdist]
40 | include = ["/src"]
41 |
42 | [tool.hatch.metadata]
43 | license-files = { paths = ["LICENSE.md"] }
44 |
45 | [tool.hatch.envs.default]
46 | installer = "uv"
47 |
48 | # >>> Hatch Test Suite <<<
49 |
50 | [tool.hatch.envs.hatch-test]
51 | extra-dependencies = ["pytest-sugar", "requests", "brotli"]
52 | randomize = true
53 | matrix-name-format = "{variable}-{value}"
54 |
55 | # Django 4.2
56 | [[tool.hatch.envs.hatch-test.matrix]]
57 | python = ["3.9", "3.10", "3.11", "3.12"]
58 | django = ["4.2"]
59 |
60 | # Django 5.0
61 | [[tool.hatch.envs.hatch-test.matrix]]
62 | python = ["3.10", "3.11", "3.12"]
63 | django = ["5.0"]
64 |
65 | # Django 5.1
66 | [[tool.hatch.envs.hatch-test.matrix]]
67 | python = ["3.10", "3.11", "3.12", "3.13"]
68 | django = ["5.1"]
69 |
70 | [tool.hatch.envs.hatch-test.overrides]
71 | matrix.django.dependencies = [
72 | { if = [
73 | "4.2",
74 | ], value = "django~=4.2" },
75 | { if = [
76 | "5.0",
77 | ], value = "django~=5.0" },
78 | { if = [
79 | "5.1",
80 | ], value = "django~=5.1" },
81 | ]
82 |
83 | # >>> Hatch Documentation Scripts <<<
84 |
85 | [tool.hatch.envs.docs]
86 | template = "docs"
87 | detached = true
88 | dependencies = [
89 | "mkdocs",
90 | "mkdocs-git-revision-date-localized-plugin",
91 | "mkdocs-material",
92 | "mkdocs-include-markdown-plugin",
93 | "linkcheckmd",
94 | "mkdocs-spellcheck[all]",
95 | "mkdocs-git-authors-plugin",
96 | "mkdocs-minify-plugin",
97 | "mike",
98 | ]
99 |
100 | [tool.hatch.envs.docs.scripts]
101 | serve = ["cd docs && mkdocs serve"]
102 | build = ["cd docs && mkdocs build --strict"]
103 | linkcheck = [
104 | "linkcheckMarkdown docs/ -v -r",
105 | "linkcheckMarkdown README.md -v -r",
106 | "linkcheckMarkdown CHANGELOG.md -v -r",
107 | ]
108 | deploy_latest = ["cd docs && mike deploy --push --update-aliases {args} latest"]
109 | deploy_develop = ["cd docs && mike deploy --push develop"]
110 |
111 | # >>> Hatch pre-commit <<<
112 |
113 | [tool.hatch.envs.precommit]
114 | template = "pre-commit"
115 | detached = true
116 | dependencies = ["pre-commit>=3,<4"]
117 |
118 | [tool.hatch.envs.precommit.scripts]
119 | check = ["pre-commit run --all-files"]
120 | update = ["pre-commit autoupdate"]
121 |
122 | # >>> Hatch Python Scripts <<<
123 |
124 | [tool.hatch.envs.python]
125 | extra-dependencies = ["django-stubs", "pyright", "brotli"]
126 |
127 | [tool.hatch.envs.python.scripts]
128 | type_check = ["pyright src"]
129 |
130 | # >>> Generic Tools <<<
131 |
132 | [tool.ruff]
133 | line-length = 120
134 | extend-exclude = [".eggs/*", ".nox/*", ".venv/*", "build/*"]
135 | format.preview = true
136 | lint.extend-ignore = [
137 | "ARG001", # Unused function argument
138 | "ARG002", # Unused method argument
139 | "ARG004", # Unused static method argument
140 | "FBT001", # Boolean-typed positional argument in function definition
141 | "FBT002", # Boolean default positional argument in function definition
142 | "PLR2004", # Magic value used in comparison
143 | "SIM115", # Use context handler for opening files
144 | "SLF001", # Private member accessed
145 | ]
146 | lint.preview = true
147 |
148 | [tool.pytest.ini_options]
149 | addopts = """\
150 | --strict-config
151 | --strict-markers
152 | """
153 |
154 | [tool.coverage.run]
155 | branch = true
156 | parallel = true
157 | source = ["src/", "tests/"]
158 |
159 | [tool.coverage.paths]
160 | source = ["src/"]
161 |
162 | [tool.coverage.report]
163 | show_missing = true
164 |
--------------------------------------------------------------------------------
/scripts/generate_default_media_types.py:
--------------------------------------------------------------------------------
1 | # pragma: no cover
2 | from __future__ import annotations
3 |
4 | import argparse
5 | import http.client
6 | import re
7 | from contextlib import closing
8 | from pathlib import Path
9 |
10 | module_dir = Path(__file__).parent.resolve()
11 | media_types_py = module_dir / "../src/servestatic/media_types.py"
12 |
13 |
14 | def main() -> int:
15 | parser = argparse.ArgumentParser()
16 | parser.add_argument("--check", action="store_true")
17 | args = parser.parse_args()
18 |
19 | func_str = get_default_types_function()
20 | text = media_types_py.read_text()
21 | new_text = re.sub(
22 | r"def default_types.*\}",
23 | func_str,
24 | text,
25 | flags=re.DOTALL,
26 | )
27 | if new_text != text:
28 | if args.check:
29 | print("Would write changes")
30 | return 1
31 | print(f"Writing {media_types_py}")
32 | media_types_py.write_text(new_text)
33 | return 0
34 |
35 |
36 | EXTRA_MIMETYPES = {
37 | # Nginx uses application/javascript, but HTML specification recommends text/javascript:
38 | ".js": "text/javascript",
39 | ".md": "text/markdown",
40 | ".mjs": "text/javascript",
41 | ".woff": "application/font-woff",
42 | ".woff2": "font/woff2",
43 | "apple-app-site-association": "application/pkc7-mime",
44 | # Adobe: https://www.adobe.com/devnet-docs/acrobatetk/tools/AppSec/xdomain.html#policy-file-host-basics
45 | "crossdomain.xml": "text/x-cross-domain-policy",
46 | }
47 |
48 |
49 | FUNCTION_TEMPLATE = '''\
50 | def default_types() -> dict[str, str]:
51 | """
52 | We use our own set of default media types rather than the system-supplied
53 | ones. This ensures consistent media type behaviour across varied
54 | environments. The defaults are based on those shipped with nginx, with
55 | some custom additions.
56 |
57 | (Auto-generated by scripts/generate_default_media_types.py)
58 | """
59 | return {{
60 | {entries}
61 | }}'''
62 |
63 |
64 | def get_default_types_function() -> str:
65 | types_map = get_types_map()
66 | lines = [f' "{suffix}": "{media_type}",' for suffix, media_type in types_map.items()]
67 | return FUNCTION_TEMPLATE.format(entries="\n".join(lines))
68 |
69 |
70 | def get_types_map() -> dict[str, str]:
71 | nginx_data = get_nginx_data()
72 | matches = re.findall(r"(\w+/.*?)\s+(.*?);", nginx_data)
73 | types_map = {}
74 | for match in matches:
75 | media_type = match[0]
76 | # This is the default media type anyway, no point specifying it explicitly
77 | if media_type == "application/octet-stream":
78 | continue
79 |
80 | extensions = match[1].split()
81 | for extension in extensions:
82 | types_map[f".{extension}"] = media_type
83 | types_map.update(EXTRA_MIMETYPES)
84 | return dict(sorted(types_map.items()))
85 |
86 |
87 | def get_nginx_data() -> str:
88 | conn = http.client.HTTPSConnection("raw.githubusercontent.com")
89 | with closing(conn):
90 | conn.request("GET", "/nginx/nginx/master/conf/mime.types")
91 | response = conn.getresponse()
92 | if response.status != 200:
93 | raise AssertionError
94 | return response.read().decode()
95 |
96 |
97 | if __name__ == "__main__":
98 | raise SystemExit(main())
99 |
--------------------------------------------------------------------------------
/scripts/validate_changelog.py:
--------------------------------------------------------------------------------
1 | # pragma: no cover
2 | # ruff: noqa: PERF401
3 |
4 | # /// script
5 | # requires-python = ">=3.11"
6 | # dependencies = []
7 | # ///
8 |
9 | import re
10 | import sys
11 |
12 | GITHUB_COMPARE_URL_START_RE = r"https?://github.com/[^/]+/[^/]+/compare/"
13 | GITHUB_COMPARE_URL_RE = GITHUB_COMPARE_URL_START_RE + r"([\w.]+)\.\.\.([\w.]+)"
14 | GITHUB_RELEASE_TAG_URL_START_RE = r"https?://github.com/[^/]+/[^/]+/releases/tag/"
15 | GITHUB_RELEASE_TAG_URL_RE = GITHUB_RELEASE_TAG_URL_START_RE + r"([\w.]+)"
16 | UNRELEASED_HEADER = "## [Unreleased]\n"
17 | VERSION_HEADER_START_RE = r"## \[([\w.]+)\]"
18 | VERSION_HEADER_FULL_RE = VERSION_HEADER_START_RE + r" - (\d{4}-\d{2}-\d{2})\n"
19 | UNRELEASED_HYPERLINK_RE = r"\[Unreleased\]: " + GITHUB_COMPARE_URL_RE + r"\n"
20 | VERSION_HYPERLINK_START_RE = r"\[([\w.]+)\]: "
21 | VERSION_HYPERLINK_RE = VERSION_HYPERLINK_START_RE + GITHUB_COMPARE_URL_RE + r"\n"
22 | INITIAL_VERSION_RE = VERSION_HYPERLINK_START_RE + GITHUB_RELEASE_TAG_URL_RE + r"\n"
23 | SECTION_HEADER_RE = r"### ([^\n]+)\n"
24 | HTML_COMMENT_RE = r"", re.DOTALL
25 |
26 |
27 | def validate_changelog(changelog_path="CHANGELOG.md"):
28 | errors = []
29 | # Read the contents of the changelog file
30 | with open(changelog_path, encoding="UTF-8") as file:
31 | changelog = file.read()
32 |
33 | # Remove markdown comments
34 | changelog = re.sub(HTML_COMMENT_RE[0], "", changelog, flags=HTML_COMMENT_RE[1])
35 | # Replace duplicate newlines with a single newline
36 | changelog = re.sub(r"\n+", "\n", changelog)
37 | # Replace duplicate spaces with a single space
38 | changelog = re.sub(r" +", " ", changelog)
39 |
40 | # Ensure `## [Unreleased]\n` is present
41 | if changelog.find(UNRELEASED_HEADER) == -1:
42 | errors.append("Changelog does contain '## [Unreleased]'")
43 |
44 | # Ensure unreleased has a URL
45 | unreleased_url = re.search(UNRELEASED_HYPERLINK_RE, changelog)
46 | if unreleased_url is None:
47 | errors.append("Unreleased does not have a URL")
48 |
49 | # Ensure UNRELEASED_URL_REGEX ends in "HEAD"
50 | if unreleased_url and unreleased_url[2] != "HEAD":
51 | errors.append(
52 | f"The hyperlink for [Unreleased] was expected to contain 'HEAD' but instead found '{unreleased_url[2]}'"
53 | )
54 |
55 | # Ensure the unreleased URL's version is the previous version (version text proceeding [Unreleased])
56 | if unreleased_url:
57 | previous_version_linked_in_unreleased = unreleased_url[1]
58 | previous_version = re.search(r"\[([^\]]+)\] -", changelog)
59 | if previous_version and previous_version[1] != previous_version_linked_in_unreleased:
60 | errors.append(
61 | f"The hyperlink for [Unreleased] was expected to contain '{previous_version[1]}' but instead found '{previous_version_linked_in_unreleased}'"
62 | )
63 |
64 | # Gather info from version headers. Note that the 'Unreleased' hyperlink is validated separately.
65 | versions_from_headers = re.findall(VERSION_HEADER_START_RE, changelog)
66 | versions_from_headers = [header for header in versions_from_headers if header != "Unreleased"]
67 | dates_from_headers = re.findall(VERSION_HEADER_FULL_RE, changelog)
68 | dates_from_headers = [header[1] for header in dates_from_headers if header[0] != "Unreleased"]
69 |
70 | # Ensure each version header has a hyperlink
71 | for version in versions_from_headers:
72 | if re.search(VERSION_HYPERLINK_START_RE.replace(r"[\w.]+", version), changelog) is None:
73 | errors.append(f"Version '{version}' does not have a URL")
74 |
75 | # Gather all hyperlinks. Note that the 'Unreleased' hyperlink is validated separately
76 | hyperlinks = re.findall(VERSION_HYPERLINK_RE, changelog)
77 | hyperlinks = [hyperlink for hyperlink in hyperlinks if hyperlink[0] != "Unreleased"]
78 |
79 | # Ensure each hyperlink has a header
80 | for hyperlink in hyperlinks:
81 | if hyperlink[0] not in versions_from_headers:
82 | errors.append(f"Hyperlink '{hyperlink[0]}' does not have a version title '## [{hyperlink[0]}]'")
83 |
84 | # Ensure there is only one initial version
85 | initial_version = re.findall(INITIAL_VERSION_RE, changelog)
86 | if len(initial_version) > 1:
87 | errors.append(
88 | "There is more than one link to a '.../releases/tag/' URL "
89 | "when this is reserved for only the initial version."
90 | )
91 |
92 | # Ensure the initial version's tag name matches the version name
93 | if initial_version:
94 | initial_version_tag = initial_version[0][0]
95 | if initial_version_tag != initial_version[0][1]:
96 | errors.append(
97 | f"The initial version tag name '{initial_version[0][1]}' does "
98 | f"not match the version header '{initial_version[0][0]}'"
99 | )
100 |
101 | # Ensure the initial version has a header
102 | if (
103 | initial_version
104 | and re.search(VERSION_HEADER_START_RE.replace(r"[\w.]+", initial_version[0][0]), changelog) is None
105 | ):
106 | errors.append(f"Initial version '{initial_version[0][0]}' does not have a version header")
107 |
108 | # Ensure all versions headers have dates
109 | full_version_headers = re.findall(VERSION_HEADER_FULL_RE, changelog)
110 | if len(full_version_headers) != len(versions_from_headers):
111 | for version in versions_from_headers:
112 | if re.search(VERSION_HEADER_FULL_RE.replace(r"([\w.]+)", rf"({version})"), changelog) is None:
113 | errors.append(f"Version header '## [{version}]' does not have a date in the correct format")
114 |
115 | # Ensure version links always diff to the previous version
116 | versions_from_hyperlinks = [hyperlinks[0] for hyperlinks in hyperlinks]
117 | versions_from_hyperlinks.append(initial_version[0][0])
118 | for position, version in enumerate(versions_from_hyperlinks):
119 | if position == len(versions_from_hyperlinks) - 1:
120 | break
121 |
122 | pattern = (
123 | rf"\[{version}\]: {GITHUB_COMPARE_URL_START_RE}{versions_from_hyperlinks[position + 1]}\.\.\.{version}"
124 | )
125 | if re.search(pattern, changelog) is None:
126 | errors.append(
127 | f"Based on hyperlink order, the URL for version '{version}' was expected to contain '.../compare/{versions_from_hyperlinks[position + 1]}...{version}'"
128 | )
129 |
130 | # Ensure the versions in the headers are in descending order
131 | for position, version in enumerate(versions_from_headers):
132 | if position == len(versions_from_headers) - 1:
133 | break
134 |
135 | if version <= versions_from_headers[position + 1]:
136 | errors.append(f"Version '{version}' should be listed before '{versions_from_headers[position + 1]}'")
137 |
138 | # Ensure the order of versions from headers matches the hyperlinks
139 | for position, version in enumerate(versions_from_headers):
140 | if position == len(versions_from_headers) - 1:
141 | break
142 |
143 | if version != versions_from_hyperlinks[position]:
144 | errors.append(
145 | f"The order of the version headers does not match your hyperlinks. "
146 | f"Found '{versions_from_hyperlinks[position]}' in hyperlinks but expected '{version}'"
147 | )
148 |
149 | # Ensure the release dates are in descending order
150 | for position, date in enumerate(dates_from_headers):
151 | if position == len(dates_from_headers) - 1:
152 | break
153 |
154 | if date < dates_from_headers[position + 1]:
155 | errors.append(f"Header with date '{date}' should be listed before '{dates_from_headers[position + 1]}'")
156 |
157 | # Check if the user is using something other than
158 | section_headers = re.findall(SECTION_HEADER_RE, changelog)
159 | for header in section_headers:
160 | if header not in {"Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"}:
161 | errors.append(f"Using non-standard section header '{header}'")
162 |
163 | # Check the order of the sections
164 | # Simplify the changelog into a list of `##` and `###` headers
165 | changelog_header_lines = [line for line in changelog.split("\n") if line.startswith(("###", "##"))]
166 | order = ["### Added", "### Changed", "### Deprecated", "### Removed", "### Fixed", "### Security"]
167 | current_position_in_order = -1
168 | version_header = "UNKNOWN"
169 | for _line in changelog_header_lines:
170 | line = _line.strip()
171 | # Reset current position if we are at a version header
172 | if line.startswith("## "):
173 | version_header = line
174 | current_position_in_order = -1
175 |
176 | # Check if the current section is in the correct order
177 | if line in order:
178 | section_position = order.index(line)
179 | if section_position < current_position_in_order:
180 | errors.append(
181 | f"Section '{line}' is out of order in version '{version_header}'. "
182 | "Expected section order: [Added, Changed, Deprecated, Removed, Fixed, Security]"
183 | )
184 | # Additional check for duplicate sections
185 | if section_position == current_position_in_order:
186 | errors.append(f"Duplicate section '{line}' found in version '{version_header}'.")
187 | current_position_in_order = section_position
188 |
189 | # Find sections with missing bullet points
190 | changelog_header_and_bullet_lines = [
191 | line for line in changelog.split("\n") if line.startswith(("### ", "## ", "-"))
192 | ]
193 | current_version = "UNKNOWN"
194 | for position, line in enumerate(changelog_header_and_bullet_lines):
195 | if line.startswith("## "):
196 | current_version = line
197 | # If it's an h3 header, report an error if the next line is not a bullet point, or if there is no next line
198 | if line.startswith("### ") and (
199 | position + 1 == len(changelog_header_and_bullet_lines)
200 | or not changelog_header_and_bullet_lines[position + 1].startswith("-")
201 | ):
202 | errors.append(f"Section '{line}' in version '{current_version}' is missing bullet points")
203 |
204 | return errors
205 |
206 |
207 | if __name__ == "__main__":
208 | if len(sys.argv) == 2:
209 | validate_changelog(sys.argv[1])
210 | if len(sys.argv) > 2:
211 | print("Usage: python validate_changelog.py [changelog_path]")
212 | sys.exit(1)
213 |
214 | errors = validate_changelog()
215 | if errors:
216 | print("Changelog has formatting errors!")
217 | for error in errors:
218 | print(f" - {error}")
219 | sys.exit(1)
220 |
221 | print("Changelog is valid!")
222 | sys.exit(0)
223 |
--------------------------------------------------------------------------------
/src/servestatic/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from servestatic.asgi import ServeStaticASGI
4 | from servestatic.wsgi import ServeStatic
5 |
6 | __version__ = "3.1.0"
7 |
8 | __all__ = ["ServeStatic", "ServeStaticASGI"]
9 |
--------------------------------------------------------------------------------
/src/servestatic/asgi.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import asyncio
4 | from typing import Callable
5 |
6 | from asgiref.compatibility import guarantee_single_callable
7 |
8 | from servestatic.base import ServeStaticBase
9 | from servestatic.utils import decode_path_info, get_block_size
10 |
11 |
12 | class ServeStaticASGI(ServeStaticBase):
13 | application: Callable
14 |
15 | async def __call__(self, scope, receive, send) -> None:
16 | # Determine if the request is for a static file
17 | static_file = None
18 | if scope["type"] == "http":
19 | path = decode_path_info(scope["path"])
20 | if self.autorefresh:
21 | static_file = await asyncio.to_thread(self.find_file, path)
22 | else:
23 | static_file = self.files.get(path)
24 |
25 | # Serve static file if it exists
26 | if static_file:
27 | return await FileServerASGI(static_file)(scope, receive, send)
28 |
29 | # Could not find a static file. Serve the default application instead.
30 | return await self.application(scope, receive, send)
31 |
32 | def initialize(self) -> None:
33 | """Ensure the ASGI application is initialized"""
34 | # If no application is provided, default to a "404 Not Found" app
35 | if not self.application:
36 | self.application = NotFoundASGI()
37 |
38 | # Ensure ASGI v2 is converted to ASGI v3
39 | self.application = guarantee_single_callable(self.application)
40 |
41 |
42 | class FileServerASGI:
43 | """Primitive ASGI v3 application that streams a StaticFile over HTTP in chunks."""
44 |
45 | def __init__(self, static_file) -> None:
46 | self.static_file = static_file
47 | self.block_size = get_block_size()
48 |
49 | async def __call__(self, scope, receive, send) -> None:
50 | # Convert ASGI headers into WSGI headers. Allows us to reuse all of our WSGI
51 | # header logic inside of aget_response().
52 | wsgi_headers = {
53 | "HTTP_" + key.decode().upper().replace("-", "_"): value.decode() for key, value in scope["headers"]
54 | }
55 | wsgi_headers["QUERY_STRING"] = scope["query_string"].decode()
56 |
57 | # Get the ServeStatic file response
58 | response = await self.static_file.aget_response(scope["method"], wsgi_headers)
59 |
60 | # Start a new HTTP response for the file
61 | await send({
62 | "type": "http.response.start",
63 | "status": response.status,
64 | "headers": [
65 | # Convert headers back to ASGI spec
66 | (key.lower().replace("_", "-").encode(), value.encode())
67 | for key, value in response.headers
68 | ],
69 | })
70 |
71 | # Head responses have no body, so we terminate early
72 | if response.file is None:
73 | await send({"type": "http.response.body", "body": b""})
74 | return
75 |
76 | # Stream the file response body
77 | async with response.file as async_file:
78 | while True:
79 | chunk = await async_file.read(self.block_size)
80 | more_body = bool(chunk)
81 | await send({
82 | "type": "http.response.body",
83 | "body": chunk,
84 | "more_body": more_body,
85 | })
86 | if not more_body:
87 | break
88 |
89 |
90 | class NotFoundASGI:
91 | """ASGI v3 application that returns a 404 Not Found response."""
92 |
93 | async def __call__(self, scope, receive, send) -> None:
94 | # Ensure this is an HTTP request
95 | if scope["type"] != "http":
96 | msg = "Default ASGI application only supports HTTP requests."
97 | raise RuntimeError(msg)
98 |
99 | # Send a 404 Not Found response
100 | await send({
101 | "type": "http.response.start",
102 | "status": 404,
103 | "headers": [(b"content-type", b"text/plain")],
104 | })
105 | await send({"type": "http.response.body", "body": b"Not Found"})
106 |
--------------------------------------------------------------------------------
/src/servestatic/base.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import contextlib
4 | import os
5 | import re
6 | import warnings
7 | from posixpath import normpath
8 | from typing import TYPE_CHECKING, Callable
9 | from wsgiref.headers import Headers
10 |
11 | from servestatic.media_types import MediaTypes
12 | from servestatic.responders import (
13 | IsDirectoryError,
14 | MissingFileError,
15 | Redirect,
16 | StaticFile,
17 | )
18 | from servestatic.utils import ensure_leading_trailing_slash, scantree
19 |
20 | if TYPE_CHECKING:
21 | from pathlib import Path
22 |
23 |
24 | class ServeStaticBase:
25 | # Ten years is what nginx sets a max age if you use 'expires max;'
26 | # so we'll follow its lead
27 | FOREVER = 10 * 365 * 24 * 60 * 60
28 |
29 | __call__: Callable
30 | """"Subclasses must implement `__call__`"""
31 |
32 | def __init__(
33 | self,
34 | application: Callable | None,
35 | root: Path | str | None = None,
36 | prefix: str | None = None,
37 | *,
38 | # Re-check the filesystem on every request so that any changes are
39 | # automatically picked up. NOTE: For use in development only, not supported
40 | # in production
41 | autorefresh: bool = False,
42 | max_age: int | None = 60, # seconds
43 | # Set 'Access-Control-Allow-Origin: *' header on all files.
44 | # As these are all public static files this is safe (See
45 | # https://www.w3.org/TR/cors/#security) and ensures that things (e.g
46 | # webfonts in Firefox) still work as expected when your static files are
47 | # served from a CDN, rather than your primary domain.
48 | allow_all_origins: bool = True,
49 | charset: str = "utf-8",
50 | mimetypes: dict[str, str] | None = None,
51 | add_headers_function: Callable[[Headers, str, str], None] | None = None,
52 | index_file: str | bool | None = None,
53 | immutable_file_test: Callable | str | None = None,
54 | ):
55 | self.autorefresh = autorefresh
56 | self.max_age = max_age
57 | self.allow_all_origins = allow_all_origins
58 | self.charset = charset
59 | self.add_headers_function = add_headers_function
60 | self._immutable_file_test = immutable_file_test
61 | self._immutable_file_test_regex: re.Pattern | None = None
62 | self.media_types = MediaTypes(extra_types=mimetypes)
63 | self.application = application
64 | self.files = {}
65 | self.directories = []
66 |
67 | if index_file is True:
68 | self.index_file: str | None = "index.html"
69 | elif isinstance(index_file, str):
70 | self.index_file = index_file
71 | else:
72 | self.index_file = None
73 |
74 | if isinstance(immutable_file_test, str):
75 | self.user_immutable_file_test = re.compile(immutable_file_test)
76 | else:
77 | self.user_immutable_file_test = immutable_file_test
78 |
79 | if root is not None:
80 | self.add_files(root, prefix)
81 |
82 | self.initialize()
83 |
84 | # ruff: noqa: PLR6301
85 | def initialize(self):
86 | """Perform any necessary setup/initialization steps."""
87 | msg = "Subclasses must implement this method."
88 | raise NotImplementedError(msg)
89 |
90 | def insert_directory(self, root, prefix):
91 | # Exit early if the directory is already in the list
92 | for existing_root, existing_prefix in self.directories:
93 | if existing_root == root and existing_prefix == prefix:
94 | return
95 |
96 | # Later calls to `add_files` overwrite earlier ones, hence we need
97 | # to store the list of directories in reverse order so later ones
98 | # match first when they're checked in "autorefresh" mode
99 | self.directories.insert(0, (root, prefix))
100 |
101 | def add_files(self, root, prefix=None):
102 | root = os.path.abspath(root)
103 | root = root.rstrip(os.path.sep) + os.path.sep
104 | prefix = ensure_leading_trailing_slash(prefix)
105 | if self.autorefresh:
106 | self.insert_directory(root, prefix)
107 | elif os.path.isdir(root):
108 | self.update_files_dictionary(root, prefix)
109 | else:
110 | warnings.warn(f"No directory at: {root}", stacklevel=3)
111 |
112 | def update_files_dictionary(self, root, prefix):
113 | # Build a mapping from paths to the results of `os.stat` calls
114 | # so we only have to touch the filesystem once
115 | stat_cache = dict(scantree(root))
116 | for path in stat_cache:
117 | relative_path = path[len(root) :]
118 | relative_url = relative_path.replace("\\", "/")
119 | url = prefix + relative_url
120 | self.add_file_to_dictionary(url, path, stat_cache=stat_cache)
121 |
122 | def add_file_to_dictionary(self, url, path, stat_cache=None):
123 | if self.is_compressed_variant(path, stat_cache=stat_cache):
124 | return
125 | if self.index_file is not None and url.endswith(f"/{self.index_file}"):
126 | index_url = url[: -len(self.index_file)]
127 | index_no_slash = index_url.rstrip("/")
128 | self.files[url] = self.redirect(url, index_url)
129 | self.files[index_no_slash] = self.redirect(index_no_slash, index_url)
130 | url = index_url
131 | static_file = self.get_static_file(path, url, stat_cache=stat_cache)
132 | self.files[url] = static_file
133 |
134 | def find_file(self, url):
135 | # Optimization: bail early if the URL can never match a file
136 | if self.index_file is None and url.endswith("/"):
137 | return
138 | if not self.url_is_canonical(url):
139 | return
140 | for path in self.candidate_paths_for_url(url):
141 | with contextlib.suppress(MissingFileError):
142 | return self.find_file_at_path(path, url)
143 | return None
144 |
145 | def candidate_paths_for_url(self, url):
146 | for root, prefix in self.directories:
147 | if url.startswith(prefix):
148 | path = os.path.join(root, url[len(prefix) :])
149 | if os.path.commonprefix((root, path)) == root:
150 | yield path
151 |
152 | def find_file_at_path(self, path, url):
153 | if self.is_compressed_variant(path):
154 | raise MissingFileError(path)
155 |
156 | if self.index_file is not None:
157 | if url.endswith("/"):
158 | path = os.path.join(path, self.index_file)
159 | return self.get_static_file(path, url)
160 | if url.endswith(f"/{self.index_file}"):
161 | if os.path.isfile(path):
162 | return self.redirect(url, url[: -len(self.index_file)])
163 | else:
164 | try:
165 | return self.get_static_file(path, url)
166 | except IsDirectoryError:
167 | if os.path.isfile(os.path.join(path, self.index_file)):
168 | return self.redirect(url, f"{url}/")
169 | raise MissingFileError(path)
170 |
171 | return self.get_static_file(path, url)
172 |
173 | @staticmethod
174 | def url_is_canonical(url):
175 | """
176 | Check that the URL path is in canonical format i.e. has normalised
177 | slashes and no path traversal elements
178 | """
179 | if "\\" in url:
180 | return False
181 | normalised = normpath(url)
182 | if url.endswith("/") and url != "/":
183 | normalised += "/"
184 | return normalised == url
185 |
186 | @staticmethod
187 | def is_compressed_variant(path, stat_cache=None):
188 | if path[-3:] in {".gz", ".br"}:
189 | uncompressed_path = path[:-3]
190 | if stat_cache is None:
191 | return os.path.isfile(uncompressed_path)
192 | return uncompressed_path in stat_cache
193 | return False
194 |
195 | def get_static_file(self, path, url, stat_cache=None):
196 | # Optimization: bail early if file does not exist
197 | if stat_cache is None and not os.path.exists(path):
198 | raise MissingFileError(path)
199 | headers = Headers([])
200 | self.add_mime_headers(headers, path, url)
201 | self.add_cache_headers(headers, path, url)
202 | if self.allow_all_origins:
203 | headers["Access-Control-Allow-Origin"] = "*"
204 | if self.add_headers_function is not None:
205 | self.add_headers_function(headers, path, url)
206 | return StaticFile(
207 | path,
208 | headers.items(),
209 | stat_cache=stat_cache,
210 | encodings={"gzip": f"{path}.gz", "br": f"{path}.br"},
211 | )
212 |
213 | def add_mime_headers(self, headers, path, url):
214 | media_type = self.media_types.get_type(path)
215 | params = {"charset": str(self.charset)} if media_type.startswith("text/") else {}
216 | headers.add_header("Content-Type", str(media_type), **params)
217 |
218 | def add_cache_headers(self, headers, path, url):
219 | if self.immutable_file_test(path, url):
220 | headers["Cache-Control"] = f"max-age={self.FOREVER}, public, immutable"
221 | elif self.max_age is not None:
222 | headers["Cache-Control"] = f"max-age={self.max_age}, public"
223 |
224 | def immutable_file_test(self, path, url):
225 | """
226 | This should be implemented by sub-classes (see e.g. ServeStaticMiddleware)
227 | or by setting the `immutable_file_test` config option
228 | """
229 | if self.user_immutable_file_test is not None:
230 | if callable(self.user_immutable_file_test):
231 | return self.user_immutable_file_test(path, url)
232 | return bool(self.user_immutable_file_test.search(url))
233 | return False
234 |
235 | def redirect(self, from_url, to_url):
236 | """
237 | Return a relative 302 redirect
238 |
239 | We use relative redirects as we don't know the absolute URL the app is
240 | being hosted under
241 | """
242 | if to_url == f"{from_url}/":
243 | relative_url = from_url.split("/")[-1] + "/"
244 | elif from_url == to_url + self.index_file:
245 | relative_url = "./"
246 | else:
247 | msg = f"Cannot handle redirect: {from_url} > {to_url}"
248 | raise ValueError(msg)
249 | headers = {"Cache-Control": f"max-age={self.max_age}, public"} if self.max_age is not None else {}
250 | return Redirect(relative_url, headers=headers)
251 |
--------------------------------------------------------------------------------
/src/servestatic/compress.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import argparse
4 | import gzip
5 | import os
6 | import re
7 | from concurrent.futures import ThreadPoolExecutor, as_completed
8 | from io import BytesIO
9 |
10 | try:
11 | import brotli
12 | except ImportError: # pragma: no cover
13 | brotli = None
14 |
15 |
16 | class Compressor:
17 | # Extensions that it's not worth trying to compress
18 | SKIP_COMPRESS_EXTENSIONS = (
19 | # Images
20 | "jpg",
21 | "jpeg",
22 | "png",
23 | "gif",
24 | "webp",
25 | # Compressed files
26 | "zip",
27 | "gz",
28 | "tgz",
29 | "bz2",
30 | "tbz",
31 | "xz",
32 | "br",
33 | # Flash
34 | "swf",
35 | "flv",
36 | # Fonts
37 | "woff",
38 | "woff2",
39 | # Video
40 | "3gp",
41 | "3gpp",
42 | "asf",
43 | "avi",
44 | "m4v",
45 | "mov",
46 | "mp4",
47 | "mpeg",
48 | "mpg",
49 | "webm",
50 | "wmv",
51 | )
52 |
53 | def __init__(self, extensions=None, use_gzip=True, use_brotli=True, log=print, quiet=False):
54 | if extensions is None:
55 | extensions = self.SKIP_COMPRESS_EXTENSIONS
56 | self.extension_re = self.get_extension_re(extensions)
57 | self.use_gzip = use_gzip
58 | self.use_brotli = use_brotli and (brotli is not None)
59 | self.log = (lambda _: None) if quiet else log
60 |
61 | @staticmethod
62 | def get_extension_re(extensions):
63 | if not extensions:
64 | return re.compile("^$")
65 | return re.compile(rf"\.({'|'.join(map(re.escape, extensions))})$", re.IGNORECASE)
66 |
67 | def should_compress(self, filename):
68 | return not self.extension_re.search(filename)
69 |
70 | def compress(self, path):
71 | filenames = []
72 | with open(path, "rb") as f:
73 | stat_result = os.fstat(f.fileno())
74 | data = f.read()
75 | size = len(data)
76 | if self.use_brotli:
77 | compressed = self.compress_brotli(data)
78 | if self.is_compressed_effectively("Brotli", path, size, compressed):
79 | filenames.append(self.write_data(path, compressed, ".br", stat_result))
80 | else:
81 | # If Brotli compression wasn't effective gzip won't be either
82 | return filenames
83 | if self.use_gzip:
84 | compressed = self.compress_gzip(data)
85 | if self.is_compressed_effectively("Gzip", path, size, compressed):
86 | filenames.append(self.write_data(path, compressed, ".gz", stat_result))
87 | return filenames
88 |
89 | @staticmethod
90 | def compress_gzip(data):
91 | output = BytesIO()
92 | # Explicitly set mtime to 0 so gzip content is fully determined
93 | # by file content (0 = "no timestamp" according to gzip spec)
94 | with gzip.GzipFile(filename="", mode="wb", fileobj=output, compresslevel=9, mtime=0) as gz_file:
95 | gz_file.write(data)
96 | return output.getvalue()
97 |
98 | @staticmethod
99 | def compress_brotli(data):
100 | if brotli is None:
101 | msg = "Brotli is not installed"
102 | raise RuntimeError(msg)
103 | return brotli.compress(data)
104 |
105 | def is_compressed_effectively(self, encoding_name, path, orig_size, data):
106 | compressed_size = len(data)
107 | if orig_size == 0:
108 | is_effective = False
109 | else:
110 | ratio = compressed_size / orig_size
111 | is_effective = ratio <= 0.95
112 | if is_effective:
113 | self.log(f"{encoding_name} compressed {path} ({orig_size // 1024}K -> {compressed_size // 1024}K)")
114 | else:
115 | self.log(f"Skipping {path} ({encoding_name} compression not effective)")
116 | return is_effective
117 |
118 | @staticmethod
119 | def write_data(path, data, suffix, stat_result):
120 | filename = path + suffix
121 | with open(filename, "wb") as f:
122 | f.write(data)
123 | os.utime(filename, (stat_result.st_atime, stat_result.st_mtime))
124 | return filename
125 |
126 |
127 | def main(argv=None):
128 | parser = argparse.ArgumentParser(
129 | description="Search for all files inside *not* matching "
130 | " and produce compressed versions with "
131 | "'.gz' and '.br' suffixes (as long as this results in a "
132 | "smaller file)"
133 | )
134 | parser.add_argument("-q", "--quiet", help="Don't produce log output", action="store_true")
135 | parser.add_argument(
136 | "--no-gzip",
137 | help="Don't produce gzip '.gz' files",
138 | action="store_false",
139 | dest="use_gzip",
140 | )
141 | parser.add_argument(
142 | "--no-brotli",
143 | help="Don't produce brotli '.br' files",
144 | action="store_false",
145 | dest="use_brotli",
146 | )
147 | parser.add_argument("root", help="Path root from which to search for files")
148 | default_exclude = ", ".join(Compressor.SKIP_COMPRESS_EXTENSIONS)
149 | parser.add_argument(
150 | "extensions",
151 | nargs="*",
152 | help=("File extensions to exclude from compression " + f"(default: {default_exclude})"),
153 | default=Compressor.SKIP_COMPRESS_EXTENSIONS,
154 | )
155 | args = parser.parse_args(argv)
156 |
157 | compressor = Compressor(
158 | extensions=args.extensions,
159 | use_gzip=args.use_gzip,
160 | use_brotli=args.use_brotli,
161 | quiet=args.quiet,
162 | )
163 |
164 | futures = []
165 | with ThreadPoolExecutor() as executor:
166 | for dirpath, _dirs, files in os.walk(args.root):
167 | futures.extend(
168 | executor.submit(compressor.compress, os.path.join(dirpath, filename))
169 | for filename in files
170 | if compressor.should_compress(filename)
171 | )
172 | # Trigger any errors
173 | for future in as_completed(futures):
174 | future.result()
175 |
176 | return 0
177 |
178 |
179 | if __name__ == "__main__": # pragma: no cover
180 | raise SystemExit(main())
181 |
--------------------------------------------------------------------------------
/src/servestatic/media_types.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import os
4 |
5 |
6 | class MediaTypes:
7 | __slots__ = ("types_map",)
8 |
9 | def __init__(self, *, extra_types: dict[str, str] | None = None) -> None:
10 | self.types_map = default_types()
11 | if extra_types is not None:
12 | self.types_map.update(extra_types)
13 |
14 | def get_type(self, path: str) -> str:
15 | name = os.path.basename(path).lower()
16 | media_type = self.types_map.get(name)
17 | if media_type is not None:
18 | return media_type
19 | extension = os.path.splitext(name)[1]
20 | return self.types_map.get(extension, "application/octet-stream")
21 |
22 |
23 | def default_types() -> dict[str, str]:
24 | """
25 | We use our own set of default media types rather than the system-supplied
26 | ones. This ensures consistent media type behaviour across varied
27 | environments. The defaults are based on those shipped with nginx, with
28 | some custom additions.
29 |
30 | (Auto-generated by scripts/generate_default_media_types.py)
31 | """
32 | return {
33 | ".3gp": "video/3gpp",
34 | ".3gpp": "video/3gpp",
35 | ".7z": "application/x-7z-compressed",
36 | ".ai": "application/postscript",
37 | ".asf": "video/x-ms-asf",
38 | ".asx": "video/x-ms-asf",
39 | ".atom": "application/atom+xml",
40 | ".avi": "video/x-msvideo",
41 | ".avif": "image/avif",
42 | ".bmp": "image/x-ms-bmp",
43 | ".cco": "application/x-cocoa",
44 | ".crt": "application/x-x509-ca-cert",
45 | ".css": "text/css",
46 | ".der": "application/x-x509-ca-cert",
47 | ".doc": "application/msword",
48 | ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
49 | ".ear": "application/java-archive",
50 | ".eot": "application/vnd.ms-fontobject",
51 | ".eps": "application/postscript",
52 | ".flv": "video/x-flv",
53 | ".gif": "image/gif",
54 | ".hqx": "application/mac-binhex40",
55 | ".htc": "text/x-component",
56 | ".htm": "text/html",
57 | ".html": "text/html",
58 | ".ico": "image/x-icon",
59 | ".jad": "text/vnd.sun.j2me.app-descriptor",
60 | ".jar": "application/java-archive",
61 | ".jardiff": "application/x-java-archive-diff",
62 | ".jng": "image/x-jng",
63 | ".jnlp": "application/x-java-jnlp-file",
64 | ".jpeg": "image/jpeg",
65 | ".jpg": "image/jpeg",
66 | ".js": "text/javascript",
67 | ".json": "application/json",
68 | ".kar": "audio/midi",
69 | ".kml": "application/vnd.google-earth.kml+xml",
70 | ".kmz": "application/vnd.google-earth.kmz",
71 | ".m3u8": "application/vnd.apple.mpegurl",
72 | ".m4a": "audio/x-m4a",
73 | ".m4v": "video/x-m4v",
74 | ".md": "text/markdown",
75 | ".mid": "audio/midi",
76 | ".midi": "audio/midi",
77 | ".mjs": "text/javascript",
78 | ".mml": "text/mathml",
79 | ".mng": "video/x-mng",
80 | ".mov": "video/quicktime",
81 | ".mp3": "audio/mpeg",
82 | ".mp4": "video/mp4",
83 | ".mpeg": "video/mpeg",
84 | ".mpg": "video/mpeg",
85 | ".odg": "application/vnd.oasis.opendocument.graphics",
86 | ".odp": "application/vnd.oasis.opendocument.presentation",
87 | ".ods": "application/vnd.oasis.opendocument.spreadsheet",
88 | ".odt": "application/vnd.oasis.opendocument.text",
89 | ".ogg": "audio/ogg",
90 | ".pdb": "application/x-pilot",
91 | ".pdf": "application/pdf",
92 | ".pem": "application/x-x509-ca-cert",
93 | ".pl": "application/x-perl",
94 | ".pm": "application/x-perl",
95 | ".png": "image/png",
96 | ".ppt": "application/vnd.ms-powerpoint",
97 | ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
98 | ".prc": "application/x-pilot",
99 | ".ps": "application/postscript",
100 | ".ra": "audio/x-realaudio",
101 | ".rar": "application/x-rar-compressed",
102 | ".rpm": "application/x-redhat-package-manager",
103 | ".rss": "application/rss+xml",
104 | ".rtf": "application/rtf",
105 | ".run": "application/x-makeself",
106 | ".sea": "application/x-sea",
107 | ".shtml": "text/html",
108 | ".sit": "application/x-stuffit",
109 | ".svg": "image/svg+xml",
110 | ".svgz": "image/svg+xml",
111 | ".swf": "application/x-shockwave-flash",
112 | ".tcl": "application/x-tcl",
113 | ".tif": "image/tiff",
114 | ".tiff": "image/tiff",
115 | ".tk": "application/x-tcl",
116 | ".ts": "video/mp2t",
117 | ".txt": "text/plain",
118 | ".war": "application/java-archive",
119 | ".wasm": "application/wasm",
120 | ".wbmp": "image/vnd.wap.wbmp",
121 | ".webm": "video/webm",
122 | ".webp": "image/webp",
123 | ".wml": "text/vnd.wap.wml",
124 | ".wmlc": "application/vnd.wap.wmlc",
125 | ".wmv": "video/x-ms-wmv",
126 | ".woff": "application/font-woff",
127 | ".woff2": "font/woff2",
128 | ".xhtml": "application/xhtml+xml",
129 | ".xls": "application/vnd.ms-excel",
130 | ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
131 | ".xml": "text/xml",
132 | ".xpi": "application/x-xpinstall",
133 | ".xspf": "application/xspf+xml",
134 | ".zip": "application/zip",
135 | "apple-app-site-association": "application/pkc7-mime",
136 | "crossdomain.xml": "text/x-cross-domain-policy",
137 | }
138 |
--------------------------------------------------------------------------------
/src/servestatic/middleware.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import asyncio
4 | import contextlib
5 | import os
6 | from posixpath import basename, normpath
7 | from urllib.parse import urlparse
8 | from urllib.request import url2pathname
9 |
10 | from asgiref.sync import iscoroutinefunction, markcoroutinefunction
11 | from django.conf import settings as django_settings
12 | from django.contrib.staticfiles import finders
13 | from django.contrib.staticfiles.storage import (
14 | ManifestStaticFilesStorage,
15 | staticfiles_storage,
16 | )
17 | from django.http import FileResponse, HttpRequest
18 |
19 | from servestatic.responders import AsyncSlicedFile, MissingFileError, Redirect, StaticFile
20 | from servestatic.storage import CompressedManifestStaticFilesStorage
21 | from servestatic.utils import (
22 | AsyncFile,
23 | AsyncFileIterator,
24 | AsyncToSyncIterator,
25 | EmptyAsyncIterator,
26 | ensure_leading_trailing_slash,
27 | stat_files,
28 | )
29 | from servestatic.wsgi import ServeStaticBase
30 |
31 | __all__ = ["ServeStaticMiddleware"]
32 |
33 |
34 | class ServeStaticMiddleware(ServeStaticBase):
35 | """
36 | Wrap ServeStatic to allow it to function as Django middleware, rather
37 | than ASGI/WSGI middleware.
38 | """
39 |
40 | async_capable = True
41 | sync_capable = False
42 |
43 | def __init__(self, get_response=None, settings=django_settings):
44 | if not iscoroutinefunction(get_response):
45 | msg = "ServeStaticMiddleware requires an async compatible version of Django."
46 | raise ValueError(msg)
47 | markcoroutinefunction(self)
48 |
49 | self.get_response = get_response
50 | debug = settings.DEBUG
51 | autorefresh = getattr(settings, "SERVESTATIC_AUTOREFRESH", debug)
52 | max_age = getattr(settings, "SERVESTATIC_MAX_AGE", 0 if debug else 60)
53 | allow_all_origins = getattr(settings, "SERVESTATIC_ALLOW_ALL_ORIGINS", True)
54 | charset = getattr(settings, "SERVESTATIC_CHARSET", "utf-8")
55 | mimetypes = getattr(settings, "SERVESTATIC_MIMETYPES", None)
56 | add_headers_function = getattr(settings, "SERVESTATIC_ADD_HEADERS_FUNCTION", None)
57 | self.index_file = getattr(settings, "SERVESTATIC_INDEX_FILE", None)
58 | immutable_file_test = getattr(settings, "SERVESTATIC_IMMUTABLE_FILE_TEST", None)
59 | self.use_finders = getattr(settings, "SERVESTATIC_USE_FINDERS", debug)
60 | self.use_manifest = getattr(
61 | settings,
62 | "SERVESTATIC_USE_MANIFEST",
63 | not debug and isinstance(staticfiles_storage, ManifestStaticFilesStorage),
64 | )
65 | self.static_prefix: str = getattr(settings, "SERVESTATIC_STATIC_PREFIX", self.default_static_prefix(settings))
66 | self.static_root = getattr(settings, "STATIC_ROOT", None)
67 | self.keep_only_hashed_files = getattr(django_settings, "SERVESTATIC_KEEP_ONLY_HASHED_FILES", False)
68 | root = getattr(settings, "SERVESTATIC_ROOT", None)
69 |
70 | super().__init__(
71 | application=lambda *_: None,
72 | autorefresh=autorefresh,
73 | max_age=max_age,
74 | allow_all_origins=allow_all_origins,
75 | charset=charset,
76 | mimetypes=mimetypes,
77 | add_headers_function=add_headers_function,
78 | index_file=self.index_file,
79 | immutable_file_test=immutable_file_test,
80 | )
81 |
82 | # Set the static prefix
83 | self.static_prefix = ensure_leading_trailing_slash(self.static_prefix)
84 |
85 | # Add the files from STATIC_ROOT, if needed
86 | if self.static_root:
87 | self.static_root = os.path.abspath(self.static_root)
88 | self.insert_directory(self.static_root, self.static_prefix)
89 |
90 | if not self.use_manifest and not self.use_finders:
91 | self.add_files(self.static_root, prefix=self.static_prefix)
92 |
93 | # Add files from the manifest, if needed
94 | if self.use_manifest:
95 | self.add_files_from_manifest()
96 |
97 | # Add files from finders, if needed
98 | if self.use_finders:
99 | self.add_files_from_finders()
100 |
101 | # Add files from the root dir, if needed
102 | if root:
103 | self.add_files(root)
104 |
105 | async def __call__(self, request):
106 | """If the URL contains a static file, serve it. Otherwise, continue to the next
107 | middleware."""
108 | if self.autorefresh:
109 | static_file = await asyncio.to_thread(self.find_file, request.path_info)
110 | else:
111 | static_file = self.files.get(request.path_info)
112 | if static_file is not None:
113 | return await self.aserve(static_file, request)
114 |
115 | if django_settings.DEBUG and request.path.startswith(django_settings.STATIC_URL):
116 | current_finders = finders.get_finders()
117 | app_dirs = [storage.location for finder in current_finders for storage in finder.storages.values()] # pyright: ignore [reportAttributeAccessIssue]
118 | app_dirs = "\n• ".join(sorted(app_dirs))
119 | msg = f"ServeStatic did not find the file '{request.path.lstrip(django_settings.STATIC_URL)}' within the following paths:\n• {app_dirs}"
120 | raise MissingFileError(msg)
121 |
122 | return await self.get_response(request)
123 |
124 | @staticmethod
125 | async def aserve(static_file: StaticFile | Redirect, request: HttpRequest):
126 | response = await static_file.aget_response(request.method, request.META)
127 | status = int(response.status)
128 | http_response = AsyncServeStaticFileResponse(
129 | response.file or EmptyAsyncIterator(),
130 | status=status,
131 | )
132 | # Remove default content-type
133 | del http_response["content-type"]
134 | for key, value in response.headers:
135 | http_response[key] = value
136 | return http_response
137 |
138 | def add_files_from_finders(self):
139 | files: dict[str, str] = {}
140 | for finder in finders.get_finders():
141 | for path, storage in finder.list(None):
142 | prefix = (getattr(storage, "prefix", None) or "").strip("/")
143 | url = "".join((
144 | self.static_prefix,
145 | prefix,
146 | "/" if prefix else "",
147 | path.replace("\\", "/"),
148 | ))
149 | # Use setdefault as only first matching file should be used
150 | files.setdefault(url, storage.path(path))
151 | self.insert_directory(storage.location, self.static_prefix)
152 |
153 | stat_cache = stat_files(files.values())
154 | for url, path in files.items():
155 | self.add_file_to_dictionary(url, path, stat_cache=stat_cache)
156 |
157 | def add_files_from_manifest(self):
158 | if not isinstance(staticfiles_storage, ManifestStaticFilesStorage):
159 | msg = "SERVESTATIC_USE_MANIFEST is set to True but staticfiles storage is not using a manifest."
160 | raise TypeError(msg)
161 | staticfiles: dict[str, str] = staticfiles_storage.hashed_files
162 |
163 | # Fetch `stat_cache` from the manifest file, if possible
164 | stat_cache = None
165 | if isinstance(staticfiles_storage, CompressedManifestStaticFilesStorage):
166 | manifest_stats: dict = staticfiles_storage.load_manifest_stats()
167 | if manifest_stats:
168 | stat_cache = {staticfiles_storage.path(k): os.stat_result(v) for k, v in manifest_stats.items()}
169 |
170 | # Add files to ServeStatic
171 | for original_name, hashed_name in staticfiles.items():
172 | # Add the original file, if it exists
173 | if not self.keep_only_hashed_files:
174 | self.add_file_to_dictionary(
175 | f"{self.static_prefix}{original_name}",
176 | staticfiles_storage.path(original_name),
177 | stat_cache=stat_cache,
178 | )
179 | # Add the hashed file
180 | self.add_file_to_dictionary(
181 | f"{self.static_prefix}{hashed_name}",
182 | staticfiles_storage.path(hashed_name),
183 | stat_cache=stat_cache,
184 | )
185 |
186 | # Add the static directory to ServeStatic
187 | if staticfiles_storage.location:
188 | self.insert_directory(staticfiles_storage.location, self.static_prefix)
189 |
190 | def candidate_paths_for_url(self, url):
191 | if self.use_finders and url.startswith(self.static_prefix):
192 | relative_url = url[len(self.static_prefix) :]
193 | path = url2pathname(relative_url)
194 | normalized_path = normpath(path).lstrip("/")
195 | path = finders.find(normalized_path)
196 | if path:
197 | yield path
198 | yield from super().candidate_paths_for_url(url)
199 |
200 | def immutable_file_test(self, path, url):
201 | """
202 | Determine whether given URL represents an immutable file (i.e. a
203 | file with a hash of its contents as part of its name) which can
204 | therefore be cached forever
205 | """
206 | if not url.startswith(self.static_prefix):
207 | return False
208 | name = url[len(self.static_prefix) :]
209 | name_without_hash = self.get_name_without_hash(name)
210 | if name == name_without_hash:
211 | return False
212 | static_url = self.get_static_url(name_without_hash)
213 | # If the static_url function maps the name without hash
214 | # back to the original name, then we know we've got a
215 | # versioned filename
216 | return bool(static_url and basename(static_url) == basename(url))
217 |
218 | @staticmethod
219 | def get_name_without_hash(filename):
220 | """
221 | Removes the version hash from a filename e.g, transforms
222 | 'css/application.f3ea4bcc2.css' into 'css/application.css'
223 |
224 | Note: this is specific to the naming scheme used by Django's
225 | CachedStaticFilesStorage. You may have to override this if
226 | you are using a different static files versioning system
227 | """
228 | name_with_hash, ext = os.path.splitext(filename)
229 | name = os.path.splitext(name_with_hash)[0]
230 | return name + ext
231 |
232 | @staticmethod
233 | def get_static_url(name):
234 | with contextlib.suppress(ValueError):
235 | return staticfiles_storage.url(name)
236 |
237 | @staticmethod
238 | def default_static_prefix(settings) -> str:
239 | force_script_name = getattr(settings, "FORCE_SCRIPT_NAME", None)
240 | static_url = getattr(settings, "STATIC_URL", None)
241 | static_prefix = urlparse(static_url or "").path
242 | if force_script_name:
243 | script_name = force_script_name.rstrip("/")
244 | if static_prefix.startswith(script_name):
245 | static_prefix = static_prefix[len(script_name) :]
246 | return static_prefix
247 |
248 | def initialize(self) -> None:
249 | """Stub that does nothing. ServeStaticMiddleware does not need to use
250 | ServeStatic's initialization hooks."""
251 |
252 |
253 | class AsyncServeStaticFileResponse(FileResponse):
254 | """
255 | Wrap Django's FileResponse with a few differences:
256 | - Prevent setting any default headers (headers are already generated by ServeStatic).
257 | - Enables async compatibility.
258 | """
259 |
260 | def set_headers(self, *args, **kwargs):
261 | pass
262 |
263 | def _set_streaming_content(self, value):
264 | # Django 4.2+ supports async file responses, but they need to be converted from
265 | # a file-like object to an iterator, otherwise Django will assume the content is
266 | # a traditional (sync) file object.
267 | if isinstance(value, (AsyncFile, AsyncSlicedFile)):
268 | value = AsyncFileIterator(value)
269 |
270 | super()._set_streaming_content(value) # pyright: ignore [reportAttributeAccessIssue]
271 |
272 | def __iter__(self):
273 | """The way that Django 4.2+ converts async to sync is inefficient, so
274 | we override it with a better implementation. Django only uses this method
275 | when running via WSGI."""
276 | try:
277 | return iter(self.streaming_content) # pyright: ignore [reportCallIssue, reportArgumentType]
278 | except TypeError:
279 | return iter(AsyncToSyncIterator(self.streaming_content)) # pyright: ignore [reportCallIssue, reportArgumentType]
280 |
--------------------------------------------------------------------------------
/src/servestatic/runserver_nostatic/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Archmonger/ServeStatic/a270833b978ff39f19817f58cda9cbe4bcbde0ad/src/servestatic/runserver_nostatic/__init__.py
--------------------------------------------------------------------------------
/src/servestatic/runserver_nostatic/management/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Archmonger/ServeStatic/a270833b978ff39f19817f58cda9cbe4bcbde0ad/src/servestatic/runserver_nostatic/management/__init__.py
--------------------------------------------------------------------------------
/src/servestatic/runserver_nostatic/management/commands/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Archmonger/ServeStatic/a270833b978ff39f19817f58cda9cbe4bcbde0ad/src/servestatic/runserver_nostatic/management/commands/__init__.py
--------------------------------------------------------------------------------
/src/servestatic/runserver_nostatic/management/commands/runserver.py:
--------------------------------------------------------------------------------
1 | """
2 | Subclass the existing 'runserver' command and change the default options
3 | to disable static file serving, allowing ServeStatic to handle static files.
4 |
5 | There is some unpleasant hackery here because we don't know which command class
6 | to subclass until runtime as it depends on which INSTALLED_APPS we have, so we
7 | have to determine this dynamically.
8 | """
9 |
10 | from __future__ import annotations
11 |
12 | import contextlib
13 | from importlib import import_module
14 | from typing import TYPE_CHECKING, cast
15 |
16 | from django.apps import apps
17 |
18 | if TYPE_CHECKING:
19 | from django.core.management.base import BaseCommand
20 |
21 |
22 | def get_next_runserver_command():
23 | """
24 | Return the next highest priority "runserver" command class
25 | """
26 | for app_name in get_lower_priority_apps():
27 | module_path = f"{app_name}.management.commands.runserver"
28 | with contextlib.suppress(ImportError, AttributeError):
29 | return import_module(module_path).Command
30 | return None
31 |
32 |
33 | def get_lower_priority_apps():
34 | """
35 | Yield all app module names below the current app in the INSTALLED_APPS list
36 | """
37 | self_app_name = ".".join(__name__.split(".")[:-3])
38 | reached_self = False
39 | for app_config in apps.get_app_configs():
40 | if app_config.name == self_app_name:
41 | reached_self = True
42 | elif reached_self:
43 | yield app_config.name
44 | yield "django.core"
45 |
46 |
47 | RunserverCommand = cast(type["BaseCommand"], get_next_runserver_command())
48 |
49 |
50 | class Command(RunserverCommand):
51 | def add_arguments(self, parser):
52 | super().add_arguments(parser)
53 | if not parser.description:
54 | parser.description = ""
55 | if parser.get_default("use_static_handler") is True:
56 | parser.set_defaults(use_static_handler=False)
57 | parser.description += "\n(Wrapped by 'servestatic.runserver_nostatic' to always enable '--nostatic')"
58 |
--------------------------------------------------------------------------------
/src/servestatic/storage.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import contextlib
4 | import errno
5 | import json
6 | import os
7 | import re
8 | import textwrap
9 | from collections.abc import Iterator
10 | from concurrent.futures import ThreadPoolExecutor, as_completed
11 | from typing import Any, Union
12 |
13 | from django.conf import settings
14 | from django.contrib.staticfiles.storage import (
15 | ManifestStaticFilesStorage,
16 | StaticFilesStorage,
17 | )
18 | from django.core.files.base import ContentFile
19 |
20 | from servestatic.compress import Compressor
21 | from servestatic.utils import stat_files
22 |
23 | _PostProcessT = Iterator[Union[tuple[str, str, bool], tuple[str, None, RuntimeError]]]
24 |
25 |
26 | class CompressedStaticFilesStorage(StaticFilesStorage):
27 | """
28 | StaticFilesStorage subclass that compresses output files.
29 | """
30 |
31 | compressor: Compressor | None
32 |
33 | def post_process(self, paths: dict[str, Any], dry_run: bool = False, **options: Any) -> _PostProcessT:
34 | if dry_run:
35 | return
36 |
37 | extensions = getattr(settings, "SERVESTATIC_SKIP_COMPRESS_EXTENSIONS", None)
38 | self.compressor = compressor = self.create_compressor(extensions=extensions, quiet=True)
39 |
40 | def _compress_path(path: str) -> list[tuple[str, str, bool]]:
41 | compressed: list[tuple[str, str, bool]] = []
42 | full_path = self.path(path)
43 | prefix_len = len(full_path) - len(path)
44 | for compressed_path in compressor.compress(full_path):
45 | compressed_name = compressed_path[prefix_len:]
46 | compressed.append((path, compressed_name, True))
47 | return compressed
48 |
49 | with ThreadPoolExecutor() as executor:
50 | futures = (executor.submit(_compress_path, path) for path in paths if compressor.should_compress(path))
51 | for future in as_completed(futures):
52 | yield from future.result()
53 |
54 | def create_compressor(self, **kwargs: Any) -> Compressor: # noqa: PLR6301
55 | return Compressor(**kwargs)
56 |
57 |
58 | class MissingFileError(ValueError):
59 | pass
60 |
61 |
62 | class CompressedManifestStaticFilesStorage(ManifestStaticFilesStorage):
63 | """
64 | Extends ManifestStaticFilesStorage instance to create compressed versions
65 | of its output files and, optionally, to delete the non-hashed files (i.e.
66 | those without the hash in their name)
67 | """
68 |
69 | _new_files = None
70 | compressor: Compressor | None
71 |
72 | def __init__(self, *args, **kwargs):
73 | self.manifest_strict = getattr(settings, "SERVESTATIC_MANIFEST_STRICT", True)
74 | super().__init__(*args, **kwargs)
75 |
76 | def post_process(self, *args, **kwargs): # pyright: ignore [reportIncompatibleMethodOverride]
77 | files = super().post_process(*args, **kwargs)
78 |
79 | if not kwargs.get("dry_run"):
80 | files = self.post_process_with_compression(files)
81 |
82 | # Make exception messages helpful
83 | for name, hashed_name, processed in files:
84 | if isinstance(processed, Exception):
85 | processed = self.make_helpful_exception(processed, name) # noqa: PLW2901
86 | yield name, hashed_name, processed
87 |
88 | self.add_stats_to_manifest()
89 |
90 | def add_stats_to_manifest(self):
91 | """Adds additional `stats` field to Django's manifest file."""
92 | current = self.read_manifest()
93 | current = json.loads(current) if current else {}
94 | payload = current | {
95 | "stats": self.stat_static_root(),
96 | }
97 | new = json.dumps(payload).encode()
98 | # Django < 3.2 doesn't have a manifest_storage attribute
99 | manifest_storage = getattr(self, "manifest_storage", self)
100 | manifest_storage.delete(self.manifest_name)
101 | manifest_storage._save(self.manifest_name, ContentFile(new)) # pyright: ignore [reportAttributeAccessIssue]
102 |
103 | def stat_static_root(self):
104 | """Stats all the files within the static root folder."""
105 | static_root = getattr(settings, "STATIC_ROOT", None)
106 | if static_root is None:
107 | return {}
108 |
109 | # If static root is a Path object, convert it to a string
110 | static_root = os.path.abspath(static_root)
111 |
112 | file_paths = []
113 | for root, _, files in os.walk(static_root):
114 | file_paths.extend(os.path.join(root, f) for f in files if f != self.manifest_name)
115 | stats = stat_files(file_paths)
116 |
117 | # Remove the static root folder from the path
118 | return {path[len(static_root) + 1 :]: stat for path, stat in stats.items()}
119 |
120 | def load_manifest_stats(self):
121 | """Derivative of Django's `load_manifest` but for the `stats` field."""
122 | content = self.read_manifest()
123 | if content is None:
124 | return {}
125 | with contextlib.suppress(json.JSONDecodeError):
126 | stored = json.loads(content)
127 | return stored.get("stats", {})
128 | msg = f"Couldn't load stats from manifest '{self.manifest_name}'"
129 | raise ValueError(msg)
130 |
131 | def post_process_with_compression(self, files):
132 | # Files may get hashed multiple times, we want to keep track of all the
133 | # intermediate files generated during the process and which of these
134 | # are the final names used for each file. As not every intermediate
135 | # file is yielded we have to hook in to the `hashed_name` method to
136 | # keep track of them all.
137 | hashed_names = {}
138 | new_files = set()
139 | self.start_tracking_new_files(new_files)
140 | for name, hashed_name, processed in files:
141 | if hashed_name and not isinstance(processed, Exception):
142 | hashed_names[self.clean_name(name)] = hashed_name
143 | yield name, hashed_name, processed
144 | self.stop_tracking_new_files()
145 | original_files = set(hashed_names.keys())
146 | hashed_files = set(hashed_names.values())
147 | if self.keep_only_hashed_files:
148 | files_to_delete = (original_files | new_files) - hashed_files
149 | files_to_compress = hashed_files
150 | else:
151 | files_to_delete = set()
152 | files_to_compress = original_files | hashed_files
153 | self.delete_files(files_to_delete)
154 | for name, compressed_name in self.compress_files(files_to_compress):
155 | yield name, compressed_name, True
156 |
157 | def hashed_name(self, *args, **kwargs):
158 | name = super().hashed_name(*args, **kwargs)
159 | if self._new_files is not None:
160 | self._new_files.add(self.clean_name(name))
161 | return name
162 |
163 | def start_tracking_new_files(self, new_files):
164 | self._new_files = new_files
165 |
166 | def stop_tracking_new_files(self):
167 | self._new_files = None
168 |
169 | @property
170 | def keep_only_hashed_files(self):
171 | return getattr(settings, "SERVESTATIC_KEEP_ONLY_HASHED_FILES", False)
172 |
173 | def delete_files(self, files_to_delete):
174 | for name in files_to_delete:
175 | try:
176 | os.unlink(self.path(name))
177 | except OSError as e:
178 | if e.errno != errno.ENOENT:
179 | raise
180 |
181 | def create_compressor(self, **kwargs): # noqa: PLR6301
182 | return Compressor(**kwargs)
183 |
184 | def compress_files(self, paths):
185 | extensions = getattr(settings, "SERVESTATIC_SKIP_COMPRESS_EXTENSIONS", None)
186 | self.compressor = compressor = self.create_compressor(extensions=extensions, quiet=True)
187 |
188 | def _compress_path(path: str) -> list[tuple[str, str]]:
189 | compressed: list[tuple[str, str]] = []
190 | full_path = self.path(path)
191 | prefix_len = len(full_path) - len(path)
192 | for compressed_path in compressor.compress(full_path):
193 | compressed_name = compressed_path[prefix_len:]
194 | compressed.append((path, compressed_name))
195 | return compressed
196 |
197 | with ThreadPoolExecutor() as executor:
198 | futures = (executor.submit(_compress_path, path) for path in paths if self.compressor.should_compress(path))
199 | for future in as_completed(futures):
200 | yield from future.result()
201 |
202 | def make_helpful_exception(self, exception, name):
203 | """
204 | If a CSS file contains references to images, fonts etc that can't be found
205 | then Django's `post_process` blows up with a not particularly helpful
206 | ValueError that leads people to think ServeStatic is broken.
207 |
208 | Here we attempt to intercept such errors and reformat them to be more
209 | helpful in revealing the source of the problem.
210 | """
211 | if isinstance(exception, ValueError):
212 | message = exception.args[0] if len(exception.args) else ""
213 | # Stringly typed exceptions. Yay!
214 | match = self._error_msg_re.search(message)
215 | if match:
216 | extension = os.path.splitext(name)[1].lstrip(".").upper()
217 | message = self._error_msg.format(
218 | orig_message=message,
219 | filename=name,
220 | missing=match.group(1),
221 | ext=extension,
222 | )
223 | exception = MissingFileError(message)
224 | return exception
225 |
226 | _error_msg_re = re.compile(r"^The file '(.+)' could not be found")
227 |
228 | _error_msg = textwrap.dedent(
229 | """\
230 | {orig_message}
231 |
232 | The {ext} file '{filename}' references a file which could not be found:
233 | {missing}
234 |
235 | Please check the URL references in this {ext} file, particularly any
236 | relative paths which might be pointing to the wrong location.
237 | """
238 | )
239 |
--------------------------------------------------------------------------------
/src/servestatic/utils.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import asyncio
4 | import concurrent.futures
5 | import contextlib
6 | import functools
7 | import os
8 | import threading
9 | from concurrent.futures import ThreadPoolExecutor
10 | from io import IOBase
11 | from typing import TYPE_CHECKING, Callable, cast
12 |
13 | if TYPE_CHECKING: # pragma: no cover
14 | from collections.abc import AsyncIterable, Iterable
15 |
16 | from servestatic.responders import AsyncSlicedFile
17 |
18 | # This is the same size as wsgiref.FileWrapper
19 | ASGI_BLOCK_SIZE = 8192
20 |
21 |
22 | def get_block_size():
23 | return ASGI_BLOCK_SIZE
24 |
25 |
26 | # Follow Django in treating URLs as UTF-8 encoded (which requires undoing the
27 | # implicit ISO-8859-1 decoding applied in Python 3). Strictly speaking, URLs
28 | # should only be ASCII anyway, but UTF-8 can be found in the wild.
29 | def decode_path_info(path_info):
30 | return path_info.encode("iso-8859-1", "replace").decode("utf-8", "replace")
31 |
32 |
33 | def ensure_leading_trailing_slash(path):
34 | path = (path or "").strip("/")
35 | return f"/{path}/" if path else "/"
36 |
37 |
38 | def scantree(root):
39 | """
40 | Recurse the given directory yielding (pathname, os.stat(pathname)) pairs
41 | """
42 | for entry in os.scandir(root):
43 | if entry.is_dir():
44 | yield from scantree(entry.path)
45 | else:
46 | yield entry.path, entry.stat()
47 |
48 |
49 | def stat_files(paths: Iterable[str]) -> dict:
50 | """Stat a list of file paths via threads."""
51 |
52 | with concurrent.futures.ThreadPoolExecutor() as executor:
53 | futures = {abs_path: executor.submit(os.stat, abs_path) for abs_path in paths}
54 | return {abs_path: future.result() for abs_path, future in futures.items()}
55 |
56 |
57 | class AsyncToSyncIterator:
58 | """Converts any async iterator to sync as efficiently as possible while retaining
59 | full compatibility with any environment.
60 |
61 | This converter must create a temporary event loop in a thread for two reasons:
62 | 1. Allows us to stream the iterator instead of buffering all contents in memory.
63 | 2. Allows the iterator to be used in environments where an event loop may not exist,
64 | or may be closed unexpectedly.
65 | """
66 |
67 | def __init__(self, iterator: AsyncIterable):
68 | self.iterator = iterator
69 |
70 | def __iter__(self):
71 | # Create a dedicated event loop to run the async iterator on.
72 | loop = asyncio.new_event_loop()
73 | thread_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix="ServeStatic")
74 |
75 | # Convert from async to sync by stepping through the async iterator and yielding
76 | # the result of each step.
77 | generator = self.iterator.__aiter__()
78 | with contextlib.suppress(GeneratorExit, StopAsyncIteration):
79 | while True:
80 | yield thread_executor.submit(loop.run_until_complete, generator.__anext__()).result()
81 | loop.close()
82 | thread_executor.shutdown(wait=True)
83 |
84 |
85 | def open_lazy(f):
86 | """Decorator that ensures the file is open before calling a function.
87 | This can be turned into a @staticmethod on `AsyncFile` once we drop Python 3.9 compatibility.
88 | """
89 |
90 | @functools.wraps(f)
91 | async def wrapper(self: AsyncFile, *args, **kwargs):
92 | if self.closed:
93 | msg = "I/O operation on closed file."
94 | raise ValueError(msg)
95 | if self.file_obj is None:
96 | self.file_obj = await self._execute(open, *self.open_args)
97 | return await f(self, *args, **kwargs)
98 |
99 | return wrapper
100 |
101 |
102 | class AsyncFile:
103 | """An async clone of the Python `open` function that utilizes threads for async file IO.
104 |
105 | This currently only covers the file operations needed by ServeStatic, but could be expanded
106 | in the future."""
107 |
108 | def __init__(
109 | self,
110 | file_path,
111 | mode: str = "r",
112 | buffering: int = -1,
113 | encoding: str | None = None,
114 | errors: str | None = None,
115 | newline: str | None = None,
116 | closefd: bool = True,
117 | opener: Callable[[str, int], int] | None = None,
118 | ):
119 | self.open_args = (
120 | file_path,
121 | mode,
122 | buffering,
123 | encoding,
124 | errors,
125 | newline,
126 | closefd,
127 | opener,
128 | )
129 | self.loop: asyncio.AbstractEventLoop | None = None
130 | self.executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ServeStatic-AsyncFile")
131 | self.lock = threading.Lock()
132 | self.file_obj: IOBase = cast(IOBase, None)
133 | self.closed = False
134 |
135 | async def _execute(self, func, *args):
136 | """Run a function in a dedicated thread (specific to each AsyncFile instance)."""
137 | if self.loop is None:
138 | self.loop = asyncio.get_event_loop()
139 | with self.lock:
140 | return await self.loop.run_in_executor(self.executor, func, *args)
141 |
142 | def open_raw(self):
143 | """Open the file without using the executor."""
144 | self.executor.shutdown(wait=True)
145 | return open(*self.open_args) # pylint: disable=unspecified-encoding
146 |
147 | async def close(self):
148 | self.closed = True
149 | if self.file_obj:
150 | await self._execute(self.file_obj.close)
151 |
152 | @open_lazy
153 | async def read(self, size=-1):
154 | return await self._execute(self.file_obj.read, size)
155 |
156 | @open_lazy
157 | async def seek(self, offset, whence=0):
158 | return await self._execute(self.file_obj.seek, offset, whence)
159 |
160 | @open_lazy
161 | async def __aenter__(self):
162 | return self
163 |
164 | async def __aexit__(self, exc_type, exc_val, exc_tb):
165 | await self.close()
166 |
167 | def __del__(self):
168 | self.executor.shutdown(wait=True)
169 |
170 |
171 | class EmptyAsyncIterator:
172 | """Placeholder async iterator for responses that have no content."""
173 |
174 | def __aiter__(self):
175 | return self
176 |
177 | async def __anext__(self):
178 | raise StopAsyncIteration
179 |
180 |
181 | class AsyncFileIterator:
182 | """Async iterator that yields chunks of data from the provided async file."""
183 |
184 | def __init__(self, async_file: AsyncFile | AsyncSlicedFile):
185 | self.async_file = async_file
186 | self.block_size = get_block_size()
187 |
188 | async def __aiter__(self):
189 | async with self.async_file as file:
190 | while True:
191 | chunk = await file.read(self.block_size)
192 | if not chunk:
193 | break
194 | yield chunk
195 |
--------------------------------------------------------------------------------
/src/servestatic/wsgi.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Callable
4 | from wsgiref.util import FileWrapper
5 |
6 | from servestatic.base import ServeStaticBase
7 | from servestatic.utils import decode_path_info
8 |
9 |
10 | class ServeStatic(ServeStaticBase):
11 | application: Callable
12 |
13 | def __call__(self, environ, start_response):
14 | # Determine if the request is for a static file
15 | path = decode_path_info(environ.get("PATH_INFO", ""))
16 | static_file = self.find_file(path) if self.autorefresh else self.files.get(path)
17 |
18 | # Serve static file if it exists
19 | if static_file:
20 | return FileServerWSGI(static_file)(environ, start_response)
21 |
22 | # Could not find a static file. Serve the default application instead.
23 | return self.application(environ, start_response)
24 |
25 | def initialize(self):
26 | """Ensure the WSGI application is initialized."""
27 | # If no application is provided, default to a "404 Not Found" app
28 | self.application = self.application or NotFoundWSGI()
29 |
30 |
31 | class FileServerWSGI:
32 | """Primitive WSGI application that streams a StaticFile over HTTP in chunks."""
33 |
34 | def __init__(self, static_file):
35 | self.static_file = static_file
36 |
37 | def __call__(self, environ, start_response):
38 | response = self.static_file.get_response(environ["REQUEST_METHOD"], environ)
39 | status_line = f"{response.status} {response.status.phrase}"
40 | start_response(status_line, list(response.headers))
41 | if response.file is not None:
42 | # Try to use a more efficient transmit method, if available
43 | file_wrapper = environ.get("wsgi.file_wrapper", FileWrapper)
44 | return file_wrapper(response.file)
45 | return []
46 |
47 |
48 | class NotFoundWSGI:
49 | """A WSGI application that returns a 404 Not Found response."""
50 |
51 | def __call__(self, environ, start_response):
52 | status = "404 Not Found"
53 | headers = [("Content-Type", "text/plain; charset=utf-8")]
54 | start_response(status, headers)
55 | return [b"Not Found"]
56 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Archmonger/ServeStatic/a270833b978ff39f19817f58cda9cbe4bcbde0ad/tests/__init__.py
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import os
4 |
5 | import django
6 | import pytest
7 |
8 |
9 | @pytest.fixture(autouse=True, scope="session")
10 | def django_setup():
11 | os.environ["DJANGO_SETTINGS_MODULE"] = "tests.django_settings"
12 | django.setup()
13 |
--------------------------------------------------------------------------------
/tests/django_settings.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import os.path
4 |
5 | from .utils import TEST_FILE_PATH, AppServer
6 |
7 | ALLOWED_HOSTS = ["*"]
8 |
9 | ROOT_URLCONF = "tests.django_urls"
10 |
11 | SECRET_KEY = "test_secret"
12 |
13 | INSTALLED_APPS = ["servestatic.runserver_nostatic", "django.contrib.staticfiles"]
14 |
15 | FORCE_SCRIPT_NAME = f"/{AppServer.PREFIX}"
16 | STATIC_URL = f"{FORCE_SCRIPT_NAME}/static/"
17 |
18 | STATIC_ROOT = os.path.join(TEST_FILE_PATH, "build")
19 |
20 | STORAGES = {
21 | "staticfiles": {
22 | "BACKEND": "servestatic.storage.CompressedManifestStaticFilesStorage",
23 | },
24 | }
25 |
26 |
27 | MIDDLEWARE = [
28 | "tests.middleware.sync_middleware_1",
29 | "tests.middleware.async_middleware_1",
30 | "servestatic.middleware.ServeStaticMiddleware",
31 | "tests.middleware.sync_middleware_2",
32 | "tests.middleware.async_middleware_2",
33 | ]
34 |
35 | LOGGING = {
36 | "version": 1,
37 | "disable_existing_loggers": False,
38 | "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}},
39 | "handlers": {"log_to_stderr": {"level": "ERROR", "class": "logging.StreamHandler"}},
40 | "loggers": {
41 | "django.request": {
42 | "handlers": ["log_to_stderr"],
43 | "level": "ERROR",
44 | "propagate": True,
45 | }
46 | },
47 | }
48 |
49 | USE_TZ = True
50 |
--------------------------------------------------------------------------------
/tests/django_urls.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from django.urls import path
4 |
5 |
6 | def avoid_django_default_welcome_page(): # pragma: no cover
7 | pass
8 |
9 |
10 | urlpatterns = [path("", avoid_django_default_welcome_page)]
11 |
--------------------------------------------------------------------------------
/tests/middleware.py:
--------------------------------------------------------------------------------
1 | from django.utils.decorators import async_only_middleware, sync_only_middleware
2 |
3 |
4 | @sync_only_middleware
5 | def sync_middleware_1(get_response):
6 | def middleware(request):
7 | return get_response(request)
8 |
9 | return middleware
10 |
11 |
12 | @async_only_middleware
13 | def async_middleware_1(get_response):
14 | async def middleware(request):
15 | return await get_response(request)
16 |
17 | return middleware
18 |
19 |
20 | @sync_only_middleware
21 | def sync_middleware_2(get_response):
22 | def middleware(request):
23 | return get_response(request)
24 |
25 | return middleware
26 |
27 |
28 | @async_only_middleware
29 | def async_middleware_2(get_response):
30 | async def middleware(request):
31 | return await get_response(request)
32 |
33 | return middleware
34 |
--------------------------------------------------------------------------------
/tests/test_asgi.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import asyncio
4 | from pathlib import Path
5 |
6 | import pytest
7 |
8 | from servestatic import utils as servestatic_utils
9 | from servestatic.asgi import ServeStaticASGI
10 |
11 | from .utils import AsgiHttpScopeEmulator, AsgiReceiveEmulator, AsgiScopeEmulator, AsgiSendEmulator, Files
12 |
13 |
14 | @pytest.fixture
15 | def test_files():
16 | return Files(
17 | js=str(Path("static") / "app.js"),
18 | index=str(Path("static") / "with-index" / "index.html"),
19 | txt=str(Path("static") / "large-file.txt"),
20 | )
21 |
22 |
23 | @pytest.fixture(params=[True, False])
24 | def application(request, test_files):
25 | """Return an ASGI application can serve the test files."""
26 |
27 | return ServeStaticASGI(None, root=test_files.directory, autorefresh=request.param, index_file=True)
28 |
29 |
30 | def test_get_js_static_file(application, test_files):
31 | scope = AsgiHttpScopeEmulator({"path": "/static/app.js"})
32 | receive = AsgiReceiveEmulator()
33 | send = AsgiSendEmulator()
34 | asyncio.run(application(scope, receive, send))
35 | assert send.body == test_files.js_content
36 | assert b"text/javascript" in send.headers[b"content-type"]
37 | assert send.headers[b"content-length"] == str(len(test_files.js_content)).encode()
38 |
39 |
40 | def test_redirect_preserves_query_string(application, test_files):
41 | scope = AsgiHttpScopeEmulator({"path": "/static/with-index", "query_string": b"v=1&x=2"})
42 | receive = AsgiReceiveEmulator()
43 | send = AsgiSendEmulator()
44 | asyncio.run(application(scope, receive, send))
45 | assert send.headers[b"location"] == b"with-index/?v=1&x=2"
46 |
47 |
48 | def test_user_app(application):
49 | scope = AsgiHttpScopeEmulator({"path": "/"})
50 | receive = AsgiReceiveEmulator()
51 | send = AsgiSendEmulator()
52 | asyncio.run(application(scope, receive, send))
53 | assert send.body == b"Not Found"
54 | assert b"text/plain" in send.headers[b"content-type"]
55 | assert send.status == 404
56 |
57 |
58 | def test_ws_scope(application):
59 | scope = AsgiHttpScopeEmulator({"type": "websocket"})
60 | receive = AsgiReceiveEmulator()
61 | send = AsgiSendEmulator()
62 | with pytest.raises(RuntimeError):
63 | asyncio.run(application(scope, receive, send))
64 |
65 |
66 | def test_lifespan_scope(application):
67 | scope = AsgiScopeEmulator({"type": "lifespan"})
68 | receive = AsgiReceiveEmulator()
69 | send = AsgiSendEmulator()
70 | with pytest.raises(RuntimeError):
71 | asyncio.run(application(scope, receive, send))
72 |
73 |
74 | def test_head_request(application, test_files):
75 | scope = AsgiHttpScopeEmulator({"path": "/static/app.js", "method": "HEAD"})
76 | receive = AsgiReceiveEmulator()
77 | send = AsgiSendEmulator()
78 | asyncio.run(application(scope, receive, send))
79 | assert send.body == b""
80 | assert b"text/javascript" in send.headers[b"content-type"]
81 | assert send.headers[b"content-length"] == str(len(test_files.js_content)).encode()
82 | assert len(send.message) == 2
83 |
84 |
85 | def test_small_block_size(application, test_files):
86 | scope = AsgiHttpScopeEmulator({"path": "/static/app.js"})
87 | receive = AsgiReceiveEmulator()
88 | send = AsgiSendEmulator()
89 |
90 | default_block_size = servestatic_utils.ASGI_BLOCK_SIZE
91 | servestatic_utils.ASGI_BLOCK_SIZE = 10
92 | asyncio.run(application(scope, receive, send))
93 | assert send[1]["body"] == test_files.js_content[:10]
94 | servestatic_utils.ASGI_BLOCK_SIZE = default_block_size
95 |
96 |
97 | def test_request_range_response(application, test_files):
98 | scope = AsgiHttpScopeEmulator({"path": "/static/app.js", "headers": [(b"range", b"bytes=0-13")]})
99 | receive = AsgiReceiveEmulator()
100 | send = AsgiSendEmulator()
101 | asyncio.run(application(scope, receive, send))
102 | assert send.body == test_files.js_content[:14]
103 |
104 |
105 | def test_out_of_range_error(application, test_files):
106 | scope = AsgiHttpScopeEmulator({"path": "/static/app.js", "headers": [(b"range", b"bytes=10000-11000")]})
107 | receive = AsgiReceiveEmulator()
108 | send = AsgiSendEmulator()
109 | asyncio.run(application(scope, receive, send))
110 | assert send.status == 416
111 | assert send.headers[b"content-range"] == b"bytes */%d" % len(test_files.js_content)
112 |
113 |
114 | def test_wrong_method_type(application, test_files):
115 | scope = AsgiHttpScopeEmulator({"path": "/static/app.js", "method": "PUT"})
116 | receive = AsgiReceiveEmulator()
117 | send = AsgiSendEmulator()
118 | asyncio.run(application(scope, receive, send))
119 | assert send.status == 405
120 |
121 |
122 | def test_large_static_file(application, test_files):
123 | scope = AsgiHttpScopeEmulator({"path": "/static/large-file.txt", "headers": []})
124 | receive = AsgiReceiveEmulator()
125 | send = AsgiSendEmulator()
126 | asyncio.run(application(scope, receive, send))
127 | assert len(send.body) == len(test_files.txt_content)
128 | assert len(send.body) == 10001
129 | assert send.body == test_files.txt_content
130 | assert send.body_count == 2
131 | assert send.headers[b"content-length"] == str(len(test_files.txt_content)).encode()
132 | assert b"text/plain" in send.headers[b"content-type"]
133 |
--------------------------------------------------------------------------------
/tests/test_compress.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import contextlib
4 | import gzip
5 | import os
6 | import re
7 | import shutil
8 | import tempfile
9 | from unittest import mock
10 |
11 | import pytest
12 |
13 | from servestatic.compress import Compressor
14 | from servestatic.compress import main as compress_main
15 |
16 | COMPRESSABLE_FILE = "application.css"
17 | TOO_SMALL_FILE = "too-small.css"
18 | WRONG_EXTENSION = "image.jpg"
19 | TEST_FILES = {COMPRESSABLE_FILE: b"a" * 1000, TOO_SMALL_FILE: b"hi"}
20 |
21 |
22 | @pytest.fixture(scope="module", autouse=True)
23 | def files_dir():
24 | # Make a temporary directory and copy in test files
25 | tmp = tempfile.mkdtemp()
26 | timestamp = 1498579535
27 | for path, contents in TEST_FILES.items():
28 | current_path = os.path.join(tmp, path.lstrip("/"))
29 | with contextlib.suppress(FileExistsError):
30 | os.makedirs(os.path.dirname(current_path))
31 | with open(current_path, "wb") as f:
32 | f.write(contents)
33 | os.utime(current_path, (timestamp, timestamp))
34 | compress_main([tmp, "--quiet"])
35 | yield tmp
36 | shutil.rmtree(tmp)
37 |
38 |
39 | def test_compresses_file(files_dir):
40 | with contextlib.closing(gzip.open(os.path.join(files_dir, f"{COMPRESSABLE_FILE}.gz"), "rb")) as f:
41 | contents = f.read()
42 | assert TEST_FILES[COMPRESSABLE_FILE] == contents
43 |
44 |
45 | def test_doesnt_compress_if_no_saving(files_dir):
46 | assert not os.path.exists(os.path.join(files_dir, f"{TOO_SMALL_FILE}gz"))
47 |
48 |
49 | def test_ignores_other_extensions(files_dir):
50 | assert not os.path.exists(os.path.join(files_dir, f"{WRONG_EXTENSION}.gz"))
51 |
52 |
53 | def test_mtime_is_preserved(files_dir):
54 | path = os.path.join(files_dir, COMPRESSABLE_FILE)
55 | gzip_path = f"{path}.gz"
56 | assert os.path.getmtime(path) == os.path.getmtime(gzip_path)
57 |
58 |
59 | def test_with_custom_extensions():
60 | compressor = Compressor(extensions=["jpg"], quiet=True)
61 | assert compressor.extension_re == re.compile(r"\.(jpg)$", re.IGNORECASE)
62 |
63 |
64 | def test_with_falsey_extensions():
65 | compressor = Compressor(quiet=True)
66 | assert compressor.get_extension_re("") == re.compile("^$")
67 |
68 |
69 | def test_custom_log():
70 | compressor = Compressor(log="test")
71 | assert compressor.log == "test"
72 |
73 |
74 | def test_compress():
75 | compressor = Compressor(use_brotli=False, use_gzip=False)
76 | assert list(compressor.compress("tests/test_files/static/styles.css")) == []
77 |
78 |
79 | def test_compressed_effectively_no_orig_size():
80 | compressor = Compressor(quiet=True)
81 | assert not compressor.is_compressed_effectively("test_encoding", "test_path", 0, "test_data")
82 |
83 |
84 | def test_main_error(files_dir):
85 | with (
86 | pytest.raises(ValueError, match="woops") as excinfo,
87 | mock.patch.object(Compressor, "compress", side_effect=ValueError("woops")),
88 | ):
89 | compress_main([files_dir, "--quiet"])
90 |
91 | assert excinfo.value.args == ("woops",)
92 |
--------------------------------------------------------------------------------
/tests/test_files/assets/compressed.css:
--------------------------------------------------------------------------------
1 | body {
2 | background-color: white;
3 | color: black;
4 | }
5 |
6 | .thing {
7 | border: 1px solid black;
8 | color: blue;
9 | }
10 |
11 | .thing2 {
12 | border: 1px solid green;
13 | color: red;
14 | }
15 |
16 | .thing3 {
17 | border: 1px solid yellow;
18 | color: purple;
19 | }
20 |
--------------------------------------------------------------------------------
/tests/test_files/assets/compressed.css.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Archmonger/ServeStatic/a270833b978ff39f19817f58cda9cbe4bcbde0ad/tests/test_files/assets/compressed.css.gz
--------------------------------------------------------------------------------
/tests/test_files/assets/custom-mime.foobar:
--------------------------------------------------------------------------------
1 | fizzbuzz
2 |
--------------------------------------------------------------------------------
/tests/test_files/assets/subdir/javascript.js:
--------------------------------------------------------------------------------
1 | var myFunction = {
2 | return 42;
3 | };
4 |
--------------------------------------------------------------------------------
/tests/test_files/assets/with-index/index.html:
--------------------------------------------------------------------------------
1 |
2 | Hello
3 |
--------------------------------------------------------------------------------
/tests/test_files/root/robots.txt:
--------------------------------------------------------------------------------
1 | Disallow: /
2 |
--------------------------------------------------------------------------------
/tests/test_files/static/app.js:
--------------------------------------------------------------------------------
1 | var myFunction = function() {
2 | return 42;
3 | };
4 |
5 | function thisFilesNeedsToBeBigEnoughToRequireGzipping(arg) {
6 | doAthing();
7 | moreThings().then(evenMoreThings);
8 | }
9 |
--------------------------------------------------------------------------------
/tests/test_files/static/directory/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Archmonger/ServeStatic/a270833b978ff39f19817f58cda9cbe4bcbde0ad/tests/test_files/static/directory/.keep
--------------------------------------------------------------------------------
/tests/test_files/static/directory/pixel.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Archmonger/ServeStatic/a270833b978ff39f19817f58cda9cbe4bcbde0ad/tests/test_files/static/directory/pixel.gif
--------------------------------------------------------------------------------
/tests/test_files/static/large-file.txt:
--------------------------------------------------------------------------------
1 | @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
2 |
--------------------------------------------------------------------------------
/tests/test_files/static/nonascii✓.txt:
--------------------------------------------------------------------------------
1 | hi
2 |
--------------------------------------------------------------------------------
/tests/test_files/static/styles.css:
--------------------------------------------------------------------------------
1 | body {
2 | background-color: white;
3 | color: black;
4 | }
5 |
6 | .thing {
7 | border: 1px solid black;
8 | color: blue;
9 | background-image: url('directory/pixel.gif');
10 | }
11 |
12 | .thing2 {
13 | border: 1px solid green;
14 | color: red;
15 | }
16 |
17 | .thing3 {
18 | border: 1px solid yellow;
19 | color: purple;
20 | }
21 |
--------------------------------------------------------------------------------
/tests/test_files/static/with-index/index.html:
--------------------------------------------------------------------------------
1 |
2 | Hello
3 |
--------------------------------------------------------------------------------
/tests/test_media_types.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from servestatic.media_types import MediaTypes
4 |
5 |
6 | def test_matched_filename():
7 | result = MediaTypes().get_type("static/apple-app-site-association")
8 | assert result == "application/pkc7-mime"
9 |
10 |
11 | def test_matched_filename_cased():
12 | result = MediaTypes().get_type("static/Apple-App-Site-Association")
13 | assert result == "application/pkc7-mime"
14 |
15 |
16 | def test_matched_extension():
17 | result = MediaTypes().get_type("static/app.js")
18 | assert result == "text/javascript"
19 |
20 |
21 | def test_unmatched_extension():
22 | result = MediaTypes().get_type("static/app.example-unmatched")
23 | assert result == "application/octet-stream"
24 |
25 |
26 | def test_extra_types():
27 | types = MediaTypes(extra_types={".js": "application/javascript"})
28 | result = types.get_type("static/app.js")
29 | assert result == "application/javascript"
30 |
--------------------------------------------------------------------------------
/tests/test_runserver_nostatic.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from django.core.management import get_commands, load_command_class
4 |
5 |
6 | def get_command_instance(name):
7 | app_name = get_commands()[name]
8 | return load_command_class(app_name, name)
9 |
10 |
11 | def test_command_output():
12 | command = get_command_instance("runserver")
13 | parser = command.create_parser("manage.py", "runserver")
14 | assert "Wrapped by 'servestatic.runserver_nostatic'" in parser.format_help()
15 | assert not parser.get_default("use_static_handler")
16 |
--------------------------------------------------------------------------------
/tests/test_servestatic.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import os
4 | import re
5 | import shutil
6 | import stat
7 | import sys
8 | import tempfile
9 | import warnings
10 | from contextlib import closing
11 | from pathlib import Path
12 | from urllib.parse import urljoin
13 | from wsgiref.headers import Headers
14 | from wsgiref.simple_server import demo_app
15 |
16 | import pytest
17 |
18 | from servestatic import ServeStatic
19 | from servestatic.responders import Redirect, StaticFile
20 |
21 | from .utils import AppServer, Files
22 |
23 |
24 | @pytest.fixture(scope="module")
25 | def files():
26 | return Files(
27 | "assets",
28 | js="subdir/javascript.js",
29 | gzip="compressed.css",
30 | gzipped="compressed.css.gz",
31 | custom_mime="custom-mime.foobar",
32 | index="with-index/index.html",
33 | )
34 |
35 |
36 | @pytest.fixture(params=[True, False], scope="module")
37 | def application(request, files):
38 | # When run all test the application with autorefresh enabled and disabled
39 | # When testing autorefresh mode we first initialise the application with an
40 | # empty temporary directory and then copy in the files afterwards so we can
41 | # test that files added after initialisation are picked up correctly
42 | if request.param:
43 | tmp = tempfile.mkdtemp()
44 | app = _init_application(tmp, autorefresh=True)
45 | copytree(files.directory, tmp)
46 | yield app
47 | shutil.rmtree(tmp)
48 | else:
49 | yield _init_application(files.directory)
50 |
51 |
52 | def _init_application(directory, **kwargs):
53 | def custom_headers(headers, path, url):
54 | if url.endswith(".css"):
55 | headers["X-Is-Css-File"] = "True"
56 |
57 | return ServeStatic(
58 | demo_app,
59 | root=directory,
60 | max_age=1000,
61 | mimetypes={".foobar": "application/x-foo-bar"},
62 | add_headers_function=custom_headers,
63 | index_file=True,
64 | **kwargs,
65 | )
66 |
67 |
68 | @pytest.fixture(scope="module")
69 | def server(application):
70 | app_server = AppServer(application)
71 | with closing(app_server):
72 | yield app_server
73 |
74 |
75 | def assert_is_default_response(response):
76 | assert "Hello world!" in response.text
77 |
78 |
79 | def test_get_file(server, files):
80 | response = server.get(files.js_url)
81 | assert response.content == files.js_content
82 | assert re.search(r"text/javascript\b", response.headers["Content-Type"])
83 | assert re.search(r'.*\bcharset="utf-8"', response.headers["Content-Type"])
84 |
85 |
86 | def test_get_not_accept_gzip(server, files):
87 | response = server.get(files.gzip_url, headers={"Accept-Encoding": ""})
88 | assert response.content == files.gzip_content
89 | assert "Content-Encoding" not in response.headers
90 | assert response.headers["Vary"] == "Accept-Encoding"
91 |
92 |
93 | def test_get_accept_star(server, files):
94 | response = server.get(files.gzip_url, headers={"Accept-Encoding": "*"})
95 | assert response.content == files.gzip_content
96 | assert "Content-Encoding" not in response.headers
97 | assert response.headers["Vary"] == "Accept-Encoding"
98 |
99 |
100 | def test_get_accept_missing(server, files):
101 | response = server.get(
102 | files.gzip_url,
103 | # Using None is required to override requests default Accept-Encoding
104 | headers={"Accept-Encoding": None},
105 | )
106 | assert response.content == files.gzip_content
107 | assert "Content-Encoding" not in response.headers
108 | assert response.headers["Vary"] == "Accept-Encoding"
109 |
110 |
111 | def test_get_accept_gzip(server, files):
112 | response = server.get(files.gzip_url)
113 | assert response.content == files.gzip_content
114 | assert response.headers["Content-Encoding"] == "gzip"
115 | assert response.headers["Vary"] == "Accept-Encoding"
116 |
117 |
118 | def test_cannot_directly_request_gzipped_file(server, files):
119 | response = server.get(f"{files.gzip_url}.gz")
120 | assert_is_default_response(response)
121 |
122 |
123 | def test_not_modified_exact(server, files):
124 | response = server.get(files.js_url)
125 | last_mod = response.headers["Last-Modified"]
126 | response = server.get(files.js_url, headers={"If-Modified-Since": last_mod})
127 | assert response.status_code == 304
128 |
129 |
130 | def test_not_modified_future(server, files):
131 | last_mod = "Fri, 11 Apr 2100 11:47:06 GMT"
132 | response = server.get(files.js_url, headers={"If-Modified-Since": last_mod})
133 | assert response.status_code == 304
134 |
135 |
136 | def test_modified(server, files):
137 | last_mod = "Fri, 11 Apr 2001 11:47:06 GMT"
138 | response = server.get(files.js_url, headers={"If-Modified-Since": last_mod})
139 | assert response.status_code == 200
140 |
141 |
142 | def test_modified_mangled_date_firefox_91_0b3(server, files):
143 | last_mod = "Fri, 16 Jul 2021 09:09:1626426577S GMT"
144 | response = server.get(files.js_url, headers={"If-Modified-Since": last_mod})
145 | assert response.status_code == 200
146 |
147 |
148 | def test_etag_matches(server, files):
149 | response = server.get(files.js_url)
150 | etag = response.headers["ETag"]
151 | response = server.get(files.js_url, headers={"If-None-Match": etag})
152 | assert response.status_code == 304
153 |
154 |
155 | def test_etag_doesnt_match(server, files):
156 | etag = '"594bd1d1-36"'
157 | response = server.get(files.js_url, headers={"If-None-Match": etag})
158 | assert response.status_code == 200
159 |
160 |
161 | def test_etag_overrules_modified_since(server, files):
162 | """
163 | Browsers send both headers so it's important that the ETag takes precedence
164 | over the last modified time, so that deploy-rollbacks are handled correctly.
165 | """
166 | headers = {
167 | "If-None-Match": '"594bd1d1-36"',
168 | "If-Modified-Since": "Fri, 11 Apr 2100 11:47:06 GMT",
169 | }
170 | response = server.get(files.js_url, headers=headers)
171 | assert response.status_code == 200
172 |
173 |
174 | def test_max_age(server, files):
175 | response = server.get(files.js_url)
176 | assert response.headers["Cache-Control"], "max-age=1000 == public"
177 |
178 |
179 | def test_other_requests_passed_through(server):
180 | response = server.get(f"/{AppServer.PREFIX}/not/static")
181 | assert_is_default_response(response)
182 |
183 |
184 | def test_non_ascii_requests_safely_ignored(server):
185 | response = server.get(f"/{AppServer.PREFIX}/test\u263a")
186 | assert_is_default_response(response)
187 |
188 |
189 | def test_add_under_prefix(server, files, application):
190 | prefix = "/prefix"
191 | application.add_files(files.directory, prefix=prefix)
192 | response = server.get(f"/{AppServer.PREFIX}{prefix}/{files.js_path}")
193 | assert response.content == files.js_content
194 |
195 |
196 | def test_response_has_allow_origin_header(server, files):
197 | response = server.get(files.js_url)
198 | assert response.headers.get("Access-Control-Allow-Origin") == "*"
199 |
200 |
201 | def test_response_has_correct_content_length_header(server, files):
202 | response = server.get(files.js_url)
203 | length = int(response.headers["Content-Length"])
204 | assert length == len(files.js_content)
205 |
206 |
207 | def test_gzip_response_has_correct_content_length_header(server, files):
208 | response = server.get(files.gzip_url)
209 | length = int(response.headers["Content-Length"])
210 | assert length == len(files.gzipped_content)
211 |
212 |
213 | def test_post_request_returns_405(server, files):
214 | response = server.request("post", files.js_url)
215 | assert response.status_code == 405
216 |
217 |
218 | def test_head_request_has_no_body(server, files):
219 | response = server.request("head", files.js_url)
220 | assert response.status_code == 200
221 | assert not response.content
222 |
223 |
224 | def test_custom_mimetype(server, files):
225 | response = server.get(files.custom_mime_url)
226 | assert re.search(r"application/x-foo-bar\b", response.headers["Content-Type"])
227 |
228 |
229 | def test_custom_headers(server, files):
230 | response = server.get(files.gzip_url)
231 | assert response.headers["x-is-css-file"] == "True"
232 |
233 |
234 | def test_index_file_served_at_directory_path(server, files):
235 | directory_url = files.index_url.rpartition("/")[0] + "/"
236 | response = server.get(directory_url)
237 | assert response.content == files.index_content
238 |
239 |
240 | def test_index_file_path_redirected(server, files):
241 | directory_url = files.index_url.rpartition("/")[0] + "/"
242 | response = server.get(files.index_url, allow_redirects=False)
243 | location = urljoin(files.index_url, response.headers["Location"])
244 | assert response.status_code == 302
245 | assert location == directory_url
246 |
247 |
248 | def test_index_file_path_redirected_with_query_string(server, files):
249 | directory_url = files.index_url.rpartition("/")[0] + "/"
250 | query_string = "v=1"
251 | response = server.get(f"{files.index_url}?{query_string}", allow_redirects=False)
252 | location = urljoin(files.index_url, response.headers["Location"])
253 | assert response.status_code == 302
254 | assert location == f"{directory_url}?{query_string}"
255 |
256 |
257 | def test_directory_path_without_trailing_slash_redirected(server, files):
258 | directory_url = files.index_url.rpartition("/")[0] + "/"
259 | no_slash_url = directory_url.rstrip("/")
260 | response = server.get(no_slash_url, allow_redirects=False)
261 | location = urljoin(no_slash_url, response.headers["Location"])
262 | assert response.status_code == 302
263 | assert location == directory_url
264 |
265 |
266 | def test_request_initial_bytes(server, files):
267 | response = server.get(files.js_url, headers={"Range": "bytes=0-13"})
268 | assert response.content == files.js_content[:14]
269 |
270 |
271 | def test_request_trailing_bytes(server, files):
272 | response = server.get(files.js_url, headers={"Range": "bytes=-3"})
273 | assert response.content == files.js_content[-3:]
274 |
275 |
276 | def test_request_middle_bytes(server, files):
277 | response = server.get(files.js_url, headers={"Range": "bytes=21-30"})
278 | assert response.content == files.js_content[21:31]
279 |
280 |
281 | def test_overlong_ranges_truncated(server, files):
282 | response = server.get(files.js_url, headers={"Range": "bytes=21-100000"})
283 | assert response.content == files.js_content[21:]
284 |
285 |
286 | def test_overlong_trailing_ranges_return_entire_file(server, files):
287 | response = server.get(files.js_url, headers={"Range": "bytes=-100000"})
288 | assert response.content == files.js_content
289 |
290 |
291 | def test_out_of_range_error(server, files):
292 | response = server.get(files.js_url, headers={"Range": "bytes=10000-11000"})
293 | assert response.status_code == 416
294 | assert response.headers["Content-Range"] == f"bytes */{len(files.js_content)}"
295 |
296 |
297 | def test_warn_about_missing_directories(application):
298 | # This is the one minor behavioural difference when autorefresh is
299 | # enabled: we don't warn about missing directories as these can be
300 | # created after the application is started
301 | if application.autorefresh:
302 | pytest.skip()
303 | with warnings.catch_warnings(record=True) as warning_list:
304 | application.add_files("/dev/null/nosuchdir\u2713")
305 | assert len(warning_list) == 1
306 |
307 |
308 | def test_handles_missing_path_info_key(application):
309 | response = application(environ={}, start_response=lambda *_args: None)
310 | assert response
311 |
312 |
313 | def test_cant_read_absolute_paths_on_windows(server):
314 | response = server.get(rf"/{AppServer.PREFIX}/C:/Windows/System.ini")
315 | assert_is_default_response(response)
316 |
317 |
318 | def test_no_error_on_very_long_filename(server):
319 | response = server.get("/blah" * 1000)
320 | assert response.status_code != 500
321 |
322 |
323 | def copytree(src, dst):
324 | for name in os.listdir(src):
325 | src_path = os.path.join(src, name)
326 | dst_path = os.path.join(dst, name)
327 | if os.path.isdir(src_path):
328 | shutil.copytree(src_path, dst_path)
329 | else:
330 | shutil.copy2(src_path, dst_path)
331 |
332 |
333 | def test_immutable_file_test_accepts_regex():
334 | instance = ServeStatic(None, immutable_file_test=r"\.test$")
335 | assert instance.immutable_file_test("", "/myfile.test")
336 | assert not instance.immutable_file_test("", "file.test.txt")
337 |
338 |
339 | @pytest.mark.skipif(sys.version_info < (3, 4), reason="Pathlib was added in Python 3.4")
340 | def test_directory_path_can_be_pathlib_instance():
341 | root = Path(Files("root").directory)
342 | # Check we can construct instance without it blowing up
343 | ServeStatic(None, root=root, autorefresh=True)
344 |
345 |
346 | def fake_stat_entry(st_mode: int = stat.S_IFREG, st_size: int = 1024, st_mtime: int = 0) -> os.stat_result:
347 | return os.stat_result((
348 | st_mode,
349 | 0, # st_ino
350 | 0, # st_dev
351 | 0, # st_nlink
352 | 0, # st_uid
353 | 0, # st_gid
354 | st_size,
355 | 0, # st_atime
356 | st_mtime,
357 | 0, # st_ctime
358 | ))
359 |
360 |
361 | def test_last_modified_not_set_when_mtime_is_zero():
362 | stat_cache = {__file__: fake_stat_entry()}
363 | responder = StaticFile(__file__, [], stat_cache=stat_cache)
364 | response = responder.get_response("GET", {})
365 | response.file.close()
366 | headers_dict = Headers(response.headers)
367 | assert "Last-Modified" not in headers_dict
368 | assert "ETag" not in headers_dict
369 |
370 |
371 | def test_file_size_matches_range_with_range_header():
372 | stat_cache = {__file__: fake_stat_entry()}
373 | responder = StaticFile(__file__, [], stat_cache=stat_cache)
374 | response = responder.get_response("GET", {"HTTP_RANGE": "bytes=0-13"})
375 | file_size = len(response.file.read())
376 | assert file_size == 14
377 |
378 |
379 | def test_chunked_file_size_matches_range_with_range_header():
380 | stat_cache = {__file__: fake_stat_entry()}
381 | responder = StaticFile(__file__, [], stat_cache=stat_cache)
382 | response = responder.get_response("GET", {"HTTP_RANGE": "bytes=0-13"})
383 | file_size = 0
384 | assert response.file is not None
385 | while response.file.read(1):
386 | file_size += 1
387 | assert file_size == 14
388 |
389 |
390 | def test_redirect_preserves_query_string():
391 | responder = Redirect("/redirect/to/here/")
392 | response = responder.get_response("GET", {"QUERY_STRING": "foo=1&bar=2"})
393 | assert response.headers[0] == ("Location", "/redirect/to/here/?foo=1&bar=2")
394 |
395 |
396 | def test_user_app():
397 | """Test that the user app is called when no static file is found."""
398 | application = ServeStatic(None)
399 | result = {}
400 |
401 | def start_response(status, headers):
402 | result["status"] = status
403 | result["headers"] = headers
404 |
405 | response = b"".join(application(environ={}, start_response=start_response))
406 |
407 | # Check if the response is a 404 Not Found
408 | assert result["status"] == "404 Not Found"
409 | assert b"Not Found" in response
410 |
--------------------------------------------------------------------------------
/tests/test_storage.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import os
4 | import re
5 | import shutil
6 | import tempfile
7 | from posixpath import basename
8 |
9 | import pytest
10 | from django.conf import settings
11 | from django.contrib.staticfiles.storage import HashedFilesMixin, staticfiles_storage
12 | from django.core.management import call_command
13 | from django.test.utils import override_settings
14 | from django.utils.functional import empty
15 |
16 | from servestatic.storage import CompressedManifestStaticFilesStorage, MissingFileError
17 |
18 | from .utils import Files
19 |
20 |
21 | @pytest.fixture
22 | def setup():
23 | staticfiles_storage._wrapped = empty
24 | files = Files("static")
25 | tmp = tempfile.mkdtemp()
26 | with override_settings(
27 | STATICFILES_DIRS=[files.directory],
28 | STATIC_ROOT=tmp,
29 | ):
30 | yield settings
31 | staticfiles_storage._wrapped = empty
32 | shutil.rmtree(tmp)
33 |
34 |
35 | @pytest.fixture
36 | def _compressed_storage(setup):
37 | backend = "servestatic.storage.CompressedStaticFilesStorage"
38 | storages = {
39 | "STORAGES": {
40 | **settings.STORAGES,
41 | "staticfiles": {"BACKEND": backend},
42 | }
43 | }
44 |
45 | with override_settings(**storages):
46 | yield
47 |
48 |
49 | @pytest.fixture
50 | def _compressed_manifest_storage(setup):
51 | backend = "servestatic.storage.CompressedManifestStaticFilesStorage"
52 | storages = {
53 | "STORAGES": {
54 | **settings.STORAGES,
55 | "staticfiles": {"BACKEND": backend},
56 | }
57 | }
58 |
59 | with override_settings(**storages, SERVESTATIC_KEEP_ONLY_HASHED_FILES=True):
60 | call_command("collectstatic", verbosity=0, interactive=False)
61 |
62 |
63 | @pytest.mark.usefixtures("_compressed_storage")
64 | def test_compressed_static_files_storage():
65 | call_command("collectstatic", verbosity=0, interactive=False)
66 |
67 | for name in ["styles.css.gz", "styles.css.br"]:
68 | path = os.path.join(settings.STATIC_ROOT, name)
69 | assert os.path.exists(path)
70 |
71 |
72 | @pytest.mark.usefixtures("_compressed_storage")
73 | def test_compressed_static_files_storage_dry_run():
74 | call_command("collectstatic", "--dry-run", verbosity=0, interactive=False)
75 |
76 | for name in ["styles.css.gz", "styles.css.br"]:
77 | path = os.path.join(settings.STATIC_ROOT, name)
78 | assert not os.path.exists(path)
79 |
80 |
81 | @pytest.mark.usefixtures("_compressed_manifest_storage")
82 | def test_make_helpful_exception():
83 | class TriggerException(HashedFilesMixin):
84 | def exists(self, path):
85 | return False
86 |
87 | exception = None
88 | try:
89 | TriggerException().hashed_name("/missing/file.png")
90 | except ValueError as e:
91 | exception = e
92 | helpful_exception = CompressedManifestStaticFilesStorage().make_helpful_exception(exception, "styles/app.css")
93 | assert isinstance(helpful_exception, MissingFileError)
94 |
95 |
96 | @pytest.mark.usefixtures("_compressed_manifest_storage")
97 | def test_unversioned_files_are_deleted():
98 | name = "styles.css"
99 | versioned_url = staticfiles_storage.url(name)
100 | versioned_name = basename(versioned_url)
101 | name_pattern = re.compile("^" + name.replace(".", r"\.([0-9a-f]+\.)?") + "$")
102 | remaining_files = [f for f in os.listdir(settings.STATIC_ROOT) if name_pattern.match(f)]
103 | assert [versioned_name] == remaining_files
104 |
105 |
106 | @pytest.mark.usefixtures("_compressed_manifest_storage")
107 | def test_manifest_file_is_left_in_place():
108 | manifest_file = os.path.join(settings.STATIC_ROOT, "staticfiles.json")
109 | assert os.path.exists(manifest_file)
110 |
111 |
112 | def test_manifest_strict_attribute_is_set():
113 | with override_settings(SERVESTATIC_MANIFEST_STRICT=True):
114 | storage = CompressedManifestStaticFilesStorage()
115 | assert storage.manifest_strict is True
116 | with override_settings(SERVESTATIC_MANIFEST_STRICT=False):
117 | storage = CompressedManifestStaticFilesStorage()
118 | assert storage.manifest_strict is False
119 |
--------------------------------------------------------------------------------
/tests/test_string_utils.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from servestatic.utils import ensure_leading_trailing_slash
4 |
5 |
6 | def test_none():
7 | assert ensure_leading_trailing_slash(None) == "/"
8 |
9 |
10 | def test_empty():
11 | assert ensure_leading_trailing_slash("") == "/"
12 |
13 |
14 | def test_slash():
15 | assert ensure_leading_trailing_slash("/") == "/"
16 |
17 |
18 | def test_contents():
19 | assert ensure_leading_trailing_slash("/foo/") == "/foo/"
20 |
21 |
22 | def test_leading():
23 | assert ensure_leading_trailing_slash("/foo") == "/foo/"
24 |
25 |
26 | def test_trailing():
27 | assert ensure_leading_trailing_slash("foo/") == "/foo/"
28 |
--------------------------------------------------------------------------------
/tests/utils.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import os
4 | import threading
5 | from wsgiref.simple_server import WSGIRequestHandler, make_server
6 | from wsgiref.util import shift_path_info
7 |
8 | import requests
9 |
10 | TEST_FILE_PATH = os.path.join(os.path.dirname(__file__), "test_files")
11 |
12 |
13 | class AppServer:
14 | """
15 | Wraps a WSGI application and allows you to make real HTTP
16 | requests against it
17 | """
18 |
19 | PREFIX = "subdir"
20 |
21 | def __init__(self, application):
22 | self.application = application
23 | self.server = make_server("127.0.0.1", 0, self.serve_under_prefix, handler_class=WSGIRequestHandler)
24 |
25 | def serve_under_prefix(self, environ, start_response):
26 | prefix = shift_path_info(environ)
27 | if prefix == self.PREFIX:
28 | return self.application(environ, start_response)
29 | start_response("404 Not Found", [])
30 | return []
31 |
32 | def get(self, *args, **kwargs):
33 | return self.request("get", *args, **kwargs)
34 |
35 | def request(self, method, path, *args, **kwargs):
36 | domain = self.server.server_address[0]
37 | port = self.server.server_address[1]
38 | url = f"http://{domain}:{port}{path}"
39 | thread = threading.Thread(target=self.server.handle_request)
40 | thread.start()
41 | response = requests.request(method, url, *args, **kwargs, timeout=5)
42 | thread.join()
43 | return response
44 |
45 | def close(self):
46 | self.server.server_close()
47 |
48 |
49 | class AsgiAppServer:
50 | def __init__(self, application):
51 | self.application = application
52 |
53 | async def __call__(self, scope, receive, send):
54 | if scope["type"] != "http": # pragma: no cover
55 | msg = "Incorrect response type!"
56 | raise RuntimeError(msg)
57 |
58 | # Remove the prefix from the path
59 | scope["path"] = scope["path"].replace(f"/{AppServer.PREFIX}", "", 1)
60 | await self.application(scope, receive, send)
61 |
62 |
63 | class Files:
64 | def __init__(self, directory="", **files):
65 | self.directory = os.path.join(TEST_FILE_PATH, directory)
66 | for name, path in files.items():
67 | url = f"/{AppServer.PREFIX}/{path}"
68 | with open(os.path.join(self.directory, path), "rb") as f:
69 | content = f.read()
70 | setattr(self, f"{name}_path", path)
71 | setattr(self, f"{name}_url", url)
72 | setattr(self, f"{name}_content", content)
73 |
74 |
75 | class AsgiScopeEmulator(dict):
76 | """
77 | Simulate a minimal ASGI scope.
78 | Individual scope values can be overridden by passing a dictionary to the constructor.
79 | """
80 |
81 | def __init__(self, scope_overrides: dict | None = None):
82 | scope = {
83 | "asgi": {"version": "3.0"},
84 | }
85 |
86 | if scope_overrides: # pragma: no cover
87 | scope.update(scope_overrides)
88 |
89 | super().__init__(scope)
90 |
91 |
92 | class AsgiHttpScopeEmulator(AsgiScopeEmulator):
93 | """
94 | Simulate a HTTP ASGI scope.
95 | Individual scope values can be overridden by passing a dictionary to the constructor.
96 | """
97 |
98 | def __init__(self, scope_overrides: dict | None = None):
99 | scope = {
100 | "client": ["127.0.0.1", 64521],
101 | "headers": [
102 | (b"host", b"127.0.0.1:8000"),
103 | (b"connection", b"keep-alive"),
104 | (
105 | b"sec-ch-ua",
106 | b'"Not/A)Brand";v="99", "Brave";v="115", "Chromium";v="115"',
107 | ),
108 | (b"sec-ch-ua-mobile", b"?0"),
109 | (b"sec-ch-ua-platform", b'"Windows"'),
110 | (b"dnt", b"1"),
111 | (b"upgrade-insecure-requests", b"1"),
112 | (
113 | b"user-agent",
114 | b"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
115 | b" (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36",
116 | ),
117 | (
118 | b"accept",
119 | b"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
120 | ),
121 | (b"sec-gpc", b"1"),
122 | (b"sec-fetch-site", b"none"),
123 | (b"sec-fetch-mode", b"navigate"),
124 | (b"sec-fetch-user", b"?1"),
125 | (b"sec-fetch-dest", b"document"),
126 | (b"accept-encoding", b"gzip, deflate, br"),
127 | (b"accept-language", b"en-US,en;q=0.9"),
128 | ],
129 | "http_version": "1.1",
130 | "method": "GET",
131 | "path": "/",
132 | "query_string": b"",
133 | "raw_path": b"/",
134 | "root_path": "",
135 | "scheme": "http",
136 | "server": ["127.0.0.1", 8000],
137 | "type": "http",
138 | }
139 |
140 | if scope_overrides: # pragma: no cover
141 | scope.update(scope_overrides)
142 |
143 | super().__init__(scope)
144 |
145 |
146 | class AsgiReceiveEmulator:
147 | """Provides a list of events to be awaited by the ASGI application. This is designed
148 | be emulate HTTP events."""
149 |
150 | def __init__(self, *events):
151 | self.events = [{"type": "http.connect"}, *list(events)]
152 |
153 | async def __call__(self):
154 | return self.events.pop(0) if self.events else {"type": "http.disconnect"}
155 |
156 |
157 | class AsgiSendEmulator:
158 | """Any events sent to this object will be stored in a list."""
159 |
160 | def __init__(self):
161 | self.message = []
162 |
163 | async def __call__(self, event):
164 | self.message.append(event)
165 |
166 | def __getitem__(self, index):
167 | return self.message[index]
168 |
169 | @property
170 | def body(self):
171 | """Combine all HTTP body messages into a single bytestring."""
172 | return b"".join([msg["body"] for msg in self.message if msg.get("body")])
173 |
174 | @property
175 | def body_count(self):
176 | """Return the number messages that contain body content."""
177 | return sum(bool(msg.get("body")) for msg in self.message)
178 |
179 | @property
180 | def headers(self):
181 | """Return the headers from the first event."""
182 | return dict(self[0]["headers"])
183 |
184 | @property
185 | def status(self):
186 | """Return the status from the first event."""
187 | return self[0]["status"]
188 |
--------------------------------------------------------------------------------