├── .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 | Website 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 | Hi! 75 | 76 | 77 | Hi! 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 | --------------------------------------------------------------------------------