├── .github ├── codecov.yml └── workflows │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGES.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── pyproject.toml ├── starlette_cramjam ├── __init__.py ├── compression.py └── middleware.py └── tests ├── __init__.py └── test_middlewares.py /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | comment: off 2 | 3 | coverage: 4 | status: 5 | project: 6 | default: 7 | target: auto 8 | threshold: 5 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - '*' 9 | pull_request: 10 | env: 11 | LATEST_PY_VERSION: '3.13' 12 | 13 | jobs: 14 | tests: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: 20 | - '3.9' 21 | - '3.10' 22 | - '3.11' 23 | - '3.12' 24 | - '3.13' 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Set up Python ${{ matrix.python-version }} 29 | uses: actions/setup-python@v5 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | 33 | - name: Install dependencies 34 | run: | 35 | python -m pip install --upgrade pip 36 | python -m pip install .["test"] 37 | 38 | - name: Run pre-commit 39 | if: ${{ matrix.python-version == env.LATEST_PY_VERSION }} 40 | run: | 41 | python -m pip install pre-commit 42 | pre-commit run --all-files 43 | 44 | - name: Run tests 45 | run: python -m pytest --cov starlette_cramjam --cov-report xml --cov-report term-missing 46 | 47 | - name: Upload Results 48 | if: ${{ matrix.python-version == env.LATEST_PY_VERSION }} 49 | uses: codecov/codecov-action@v4 50 | with: 51 | file: ./coverage.xml 52 | flags: unittests 53 | name: ${{ matrix.python-version }} 54 | fail_ci_if_error: false 55 | 56 | publish: 57 | needs: [tests] 58 | runs-on: ubuntu-latest 59 | if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' 60 | steps: 61 | - uses: actions/checkout@v4 62 | - name: Set up Python 63 | uses: actions/setup-python@v5 64 | with: 65 | python-version: ${{ env.LATEST_PY_VERSION }} 66 | 67 | - name: Install dependencies 68 | run: | 69 | python -m pip install --upgrade pip 70 | python -m pip install flit 71 | python -m pip install . 72 | 73 | - name: Set tag version 74 | id: tag 75 | run: | 76 | echo "version=${GITHUB_REF#refs/*/}" 77 | echo "version=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT 78 | 79 | - name: Set module version 80 | id: module 81 | run: | 82 | echo version=$(python -c'import starlette_cramjam; print(starlette_cramjam.__version__)') >> $GITHUB_OUTPUT 83 | 84 | - name: Build and publish 85 | if: ${{ steps.tag.outputs.version }} == ${{ steps.module.outputs.version}} 86 | env: 87 | FLIT_USERNAME: ${{ secrets.PYPI_USERNAME }} 88 | FLIT_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 89 | run: flit publish 90 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/abravalheri/validate-pyproject 3 | rev: v0.12.1 4 | hooks: 5 | - id: validate-pyproject 6 | 7 | - repo: https://github.com/PyCQA/isort 8 | rev: 5.13.2 9 | hooks: 10 | - id: isort 11 | language_version: python 12 | 13 | - repo: https://github.com/astral-sh/ruff-pre-commit 14 | rev: v0.3.5 15 | hooks: 16 | - id: ruff 17 | args: ["--fix"] 18 | - id: ruff-format 19 | 20 | - repo: https://github.com/pre-commit/mirrors-mypy 21 | rev: v1.9.0 22 | hooks: 23 | - id: mypy 24 | language_version: python 25 | # No reason to run if only tests have changed. They intentionally break typing. 26 | exclude: tests/.* 27 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | ## 0.5.0 (2025-04-18) 2 | 3 | - add support for `zstd` compression 4 | - remove support for python 3.7 and 3. 5 | 6 | ## 0.4.0 (2024-10-17) 7 | 8 | - add `compression_level` option 9 | - update `cramjam` version limit to `cramjam>=2.4,<2.10` 10 | 11 | ## 0.3.3 (2024-05-24) 12 | 13 | - add python 3.12 and update pyrus-cramjam version dependency 14 | 15 | ## 0.3.2 (2022-10-28) 16 | 17 | - add python 3.11 and update pyrus-cramjam version dependency 18 | 19 | ## 0.3.1 (2022-09-08) 20 | 21 | - update `pyrus-cramjam` version requirement to allow `2.5.0` 22 | 23 | ## 0.3.0 (2022-06-07) 24 | 25 | - add `compression` parameter to define compression backend and order of preference 26 | - defaults to `gzip` -> `deflate` -> `br` order of preference (instead of `br` -> `gzip` -> `deflate`) 27 | - remove `exclude_encoder` parameter **breaking** 28 | - allow encoding `quality` values (e.g `gzip;0.9, deflate;0.2`) 29 | 30 | ## 0.2.0 (2022-06-03) 31 | 32 | - switch to `pyproject.toml` 33 | - move version definition to `starlette_cramjam.__version__` 34 | - Add `exclude_encoder` parameter (author @drnextgis, https://github.com/developmentseed/starlette-cramjam/pull/4) 35 | 36 | ## 0.1.0 (2021-09-17) 37 | 38 | - No change since alpha release 39 | 40 | ## 0.1.0.a0 (2021-09-08) 41 | 42 | - Initial release. 43 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Issues and pull requests are more than welcome. 4 | 5 | ### dev install 6 | 7 | ```bash 8 | $ git clone https://github.com/developmentseed/starlette-cramjam.git 9 | $ cd starlette-cramjam 10 | $ pip install -e .["test,dev"] 11 | ``` 12 | 13 | You can then run the tests with the following command: 14 | 15 | ```python 16 | python -m pytest --cov starlette_cramjam --cov-report xml --cov-report term-missing 17 | ``` 18 | 19 | ### pre-commit 20 | 21 | This repo is set to use `pre-commit` to run *isort*, *flake8*, *pydocstring*, *black* ("uncompromising Python code formatter") and mypy when committing new code. 22 | 23 | ```bash 24 | $ pre-commit install 25 | ``` 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Development Seed 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # starlette-cramjam 2 | 3 |

4 | Cramjam integration for Starlette ASGI framework. 5 |

6 |

7 | 8 | Test 9 | 10 | 11 | Coverage 12 | 13 | 14 | Package version 15 | 16 | 17 | Downloads 18 | 19 | 20 | Downloads 21 | 22 |

23 | 24 | --- 25 | 26 | **Source Code**: https://github.com/developmentseed/starlette-cramjam 27 | 28 | --- 29 | 30 | The `starlette-cramjam` middleware aims to provide a unique Compression middleware to support **Brotli**, **GZip**, **Deflate** and **ZSTD** compression algorithms with a minimal requirement. 31 | 32 | The middleware will compress responses for any request that includes "br", "gzip", "deflate" or "zstd" in the Accept-Encoding header. 33 | 34 | As for the official `Starlette` middleware, the one provided by `starlette-cramjam` will handle both standard and streaming responses. 35 | 36 | `stralette-cramjam` is built on top of [pyrus-cramjam](https://github.com/milesgranger/pyrus-cramjam) an *Extremely thin Python bindings to de/compression algorithms in Rust*. 37 | 38 | ## Installation 39 | 40 | You can install `starlette-cramjam` from pypi 41 | 42 | ```python 43 | $ pip install -U pip 44 | $ pip install starlette-cramjam 45 | ``` 46 | 47 | or install from source: 48 | 49 | ```bash 50 | $ pip install -U pip 51 | $ pip install https://github.com/developmentseed/starlette-cramjam.git 52 | ``` 53 | 54 | ## Usage 55 | 56 | The following arguments are supported: 57 | 58 | - **compression** (List of Compression) - List of available compression algorithm. **This list also defines the order of preference**. Defaults to `[Compression.gzip, Compression.deflate, Compression.br, Compression.zstd]`, 59 | - **compression_level** (Integer) - Compression level to use, form `0` (None) to `11` (High). Defaults to cramjam internal defaults for each compression backend. 60 | - **minimum_size** (Integer) - Do not compress responses that are smaller than this minimum size in bytes. Defaults to `500`. 61 | - **exclude_path** (Set of string) - Do not compress responses in response to specific `path` requests. Entries have to be valid regex expressions. Defaults to `{}`. 62 | - **exclude_mediatype** (Set of string) - Do not compress responses of specific media type (e.g `image/png`). Defaults to `{}`. 63 | 64 | #### Minimal (defaults) example 65 | 66 | ```python 67 | import uvicorn 68 | 69 | from starlette.applications import Starlette 70 | from starlette.middleware import Middleware 71 | from starlette.responses import PlainTextResponse 72 | from starlette.routing import Route 73 | 74 | from starlette_cramjam.middleware import CompressionMiddleware 75 | 76 | def index(request): 77 | return PlainTextResponse("Hello World") 78 | 79 | 80 | app = Starlette( 81 | routes=[Route("/", endpoint=index)], 82 | middleware=[ 83 | Middleware(CompressionMiddleware), 84 | ], 85 | ) 86 | 87 | if __name__ == "__main__": 88 | uvicorn.run(app, host="0.0.0.0", port=8000) 89 | ``` 90 | 91 | #### Using options 92 | 93 | ```python 94 | import uvicorn 95 | 96 | from starlette.applications import Starlette 97 | from starlette.middleware import Middleware 98 | from starlette.responses import PlainTextResponse, Response 99 | from starlette.routing import Route 100 | 101 | from starlette_cramjam.compression import Compression 102 | from starlette_cramjam.middleware import CompressionMiddleware 103 | 104 | def index(request): 105 | return PlainTextResponse("Hello World") 106 | 107 | def img(request): 108 | return Response(b"This is a fake body", status_code=200, media_type="image/jpeg") 109 | 110 | def foo(request): 111 | return PlainTextResponse("Do not compress me.") 112 | 113 | 114 | app = Starlette( 115 | routes=[ 116 | Route("/", endpoint=index), 117 | Route("/image", endpoint=img), 118 | Route("/foo", endpoint=foo), 119 | ], 120 | middleware=[ 121 | Middleware( 122 | CompressionMiddleware, 123 | compression=[Compression.gzip], # Only support `gzip` 124 | compression_level=6, # Compression level to use 125 | minimum_size=0, # should compress everything 126 | exclude_path={"^/foo$"}, # do not compress response for the `/foo` request 127 | exclude_mediatype={"image/jpeg"}, # do not compress jpeg 128 | ), 129 | ], 130 | ) 131 | 132 | if __name__ == "__main__": 133 | uvicorn.run(app, host="0.0.0.0", port=8000) 134 | ``` 135 | 136 | ## Performance 137 | 138 | ```python 139 | import gzip 140 | import sys 141 | 142 | import brotli 143 | import cramjam 144 | import httpx 145 | 146 | page = httpx.get("https://github.com/developmentseed/starlette-cramjam").content 147 | 148 | len(page) 149 | # 347686 150 | 151 | %timeit brotli.compress(page, quality=4) 152 | # 1.77 ms ± 19.7 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each) 153 | 154 | sys.getsizeof(brotli.compress(page, quality=4)) 155 | # 48766 156 | 157 | %timeit gzip.compress(page, compresslevel=6) 158 | # 4.62 ms ± 28 µs per loop (mean ± std. dev. of 7 runs, 100 loops each) 159 | 160 | sys.getsizeof(gzip.compress(page, compresslevel=6)) 161 | # 54888 162 | 163 | # ------------ 164 | # With Cramjam 165 | # ------------ 166 | %timeit cramjam.gzip.compress(page, level=6) 167 | # 4.12 ms ± 57.3 µs per loop (mean ± std. dev. of 7 runs, 100 loops each) 168 | 169 | cramjam.gzip.compress(page, level=6).len() 170 | # 55221 171 | 172 | %timeit cramjam.brotli.compress(page, level=4) 173 | # 2.3 ms ± 48.5 µs per loop (mean ± std. dev. of 7 runs, 100 loops each) 174 | 175 | cramjam.brotli.compress(page, level=4).len() 176 | # 48742 177 | ``` 178 | 179 | Ref: https://github.com/fullonic/brotli-asgi?tab=readme-ov-file#performance 180 | 181 | ## Changes 182 | 183 | See [CHANGES.md](https://github.com/developmentseed/starlette-cramjam/blob/master/CHANGES.md). 184 | 185 | ## Contribution & Development 186 | 187 | See [CONTRIBUTING.md](https://github.com/developmentseed/starlette-cramjam/blob/master/CONTRIBUTING.md) 188 | 189 | ## License 190 | 191 | See [LICENSE](https://github.com/developmentseed/starlette-cramjam/blob/master/LICENSE) 192 | 193 | ## Authors 194 | 195 | Created by [Development Seed]() 196 | 197 | See [contributors](https://github.com/developmentseed/starlette-cramjam/graphs/contributors) for a listing of individual contributors. 198 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "starlette-cramjam" 3 | description = "Cramjam integration for Starlette ASGI framework." 4 | readme = "README.md" 5 | requires-python = ">=3.7" 6 | license = {file = "LICENSE"} 7 | authors = [ 8 | {name = "Vincent Sarago", email = "vincent@developmentseed.com"}, 9 | ] 10 | keywords = ["Cramjam", "Compression", "ASGI", "Starlette"] 11 | classifiers = [ 12 | "Intended Audience :: Information Technology", 13 | "Intended Audience :: Science/Research", 14 | "License :: OSI Approved :: MIT License", 15 | "Programming Language :: Python :: 3.9", 16 | "Programming Language :: Python :: 3.10", 17 | "Programming Language :: Python :: 3.11", 18 | "Programming Language :: Python :: 3.12", 19 | "Programming Language :: Python :: 3.13", 20 | "Typing :: Typed", 21 | ] 22 | dynamic = ["version"] 23 | dependencies = ["starlette", "cramjam>=2.4,<2.11"] 24 | 25 | [project.optional-dependencies] 26 | test = [ 27 | "pytest", 28 | "pytest-cov", 29 | "httpx", 30 | "brotlipy", 31 | "zstandard", 32 | ] 33 | dev = [ 34 | "pre-commit", 35 | "bump-my-version", 36 | ] 37 | 38 | [project.urls] 39 | Source = "https://github.com/developmentseed/starlette-cramjam" 40 | 41 | [build-system] 42 | requires = ["flit>=3.2,<4"] 43 | build-backend = "flit_core.buildapi" 44 | 45 | [tool.flit.module] 46 | name = "starlette_cramjam" 47 | 48 | [tool.flit.sdist] 49 | exclude = [ 50 | "tests/", 51 | ".github/", 52 | "CHANGES.md", 53 | "CONTRIBUTING.md", 54 | ] 55 | 56 | [tool.isort] 57 | profile = "black" 58 | known_first_party = ["starlette_cramjam"] 59 | known_third_party = ["starlette", "cramjam"] 60 | default_section = "THIRDPARTY" 61 | 62 | [tool.mypy] 63 | no_strict_optional = "True" 64 | 65 | [tool.ruff.lint] 66 | select = [ 67 | "D1", # pydocstyle errors 68 | "E", # pycodestyle errors 69 | "W", # pycodestyle warnings 70 | "F", # flake8 71 | "C", # flake8-comprehensions 72 | "B", # flake8-bugbear 73 | ] 74 | ignore = [ 75 | "E501", # line too long, handled by black 76 | "B008", # do not perform function calls in argument defaults 77 | "B905", # ignore zip() without an explicit strict= parameter, only support with python >3.10 78 | "B028", 79 | ] 80 | 81 | [tool.ruff] 82 | line-length = 90 83 | 84 | [tool.ruff.lint.per-file-ignores] 85 | "tests/*.py" = ["D1"] 86 | 87 | [tool.ruff.lint.mccabe] 88 | max-complexity = 12 89 | 90 | [tool.bumpversion] 91 | current_version = "0.5.0" 92 | search = "{current_version}" 93 | replace = "{new_version}" 94 | regex = false 95 | tag = true 96 | commit = true 97 | tag_name = "{new_version}" 98 | 99 | [[tool.bumpversion.files]] 100 | filename = "starlette_cramjam/__init__.py" 101 | search = '__version__ = "{current_version}"' 102 | replace = '__version__ = "{new_version}"' 103 | -------------------------------------------------------------------------------- /starlette_cramjam/__init__.py: -------------------------------------------------------------------------------- 1 | """starlette_cramjam.""" 2 | 3 | __version__ = "0.5.0" 4 | -------------------------------------------------------------------------------- /starlette_cramjam/compression.py: -------------------------------------------------------------------------------- 1 | """starlette_cramjam.compression.""" 2 | 3 | from enum import Enum 4 | from types import DynamicClassAttribute 5 | 6 | import cramjam 7 | 8 | compression_backends = { 9 | "br": cramjam.brotli, # min: 0, max: 11, default: 11 10 | "deflate": cramjam.deflate, # min: 0, max: 9, default: 6 11 | "gzip": cramjam.gzip, # min: 0, max: 9, default: 6 12 | "zstd": cramjam.zstd, # min: 0, default: 0 13 | } 14 | 15 | 16 | class Compression(str, Enum): 17 | """Responses Media types formerly known as MIME types.""" 18 | 19 | gzip = "gzip" 20 | br = "br" 21 | deflate = "deflate" 22 | zstd = "zstd" 23 | 24 | @DynamicClassAttribute 25 | def compress(self): 26 | """Return cramjam backend.""" 27 | return compression_backends[self._name_] 28 | -------------------------------------------------------------------------------- /starlette_cramjam/middleware.py: -------------------------------------------------------------------------------- 1 | """starlette_cramjam.middleware.""" 2 | 3 | import re 4 | from typing import Any, List, Optional, Set 5 | 6 | from starlette.datastructures import Headers, MutableHeaders 7 | from starlette.types import ASGIApp, Message, Receive, Scope, Send 8 | 9 | from starlette_cramjam.compression import Compression 10 | 11 | ACCEPT_ENCODING_PATTERN = r"^(?P[a-z]+|\*)(;q=(?P[\w,.]+))?" 12 | 13 | DEFAULT_BACKENDS = [ 14 | Compression.gzip, 15 | Compression.deflate, 16 | Compression.br, 17 | Compression.zstd, 18 | ] 19 | 20 | 21 | def get_compression_backend( 22 | accepted_encoding: str, compressions: List[Compression] 23 | ) -> Optional[Compression]: 24 | """Return Compression backend based on default compression and accepted preference. 25 | 26 | Links: 27 | - https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html 28 | - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding 29 | 30 | """ 31 | # Parse Accepted-Encoding value `gzip, deflate;q=0.1` 32 | encoding_values = {} 33 | for encoding in accepted_encoding.replace(" ", "").split(","): 34 | matched = re.match(ACCEPT_ENCODING_PATTERN, encoding) 35 | if matched: 36 | name, q = matched.groupdict().values() 37 | try: 38 | quality = float(q) if q else 1.0 39 | except ValueError: 40 | quality = 0 41 | 42 | # if quality is 0 we ignore encoding 43 | if quality: 44 | encoding_values[name] = quality 45 | 46 | # Create Preference matrix 47 | encoding_preference = { 48 | v: [n for (n, q) in encoding_values.items() if q == v] 49 | for v in sorted({q for q in encoding_values.values()}, reverse=True) # noqa: C416 50 | } 51 | 52 | # Loop through available compression and encoding preference 53 | for _, pref in encoding_preference.items(): 54 | for backend in compressions: 55 | if backend.name in pref: 56 | return backend 57 | 58 | # If no specified encoding is supported but "*" is accepted, 59 | # take one of the available compressions. 60 | if "*" in encoding_values and compressions: 61 | return compressions[0] 62 | 63 | return None 64 | 65 | 66 | class CompressionMiddleware: 67 | """Starlette Cramjam MiddleWare.""" 68 | 69 | def __init__( 70 | self, 71 | app: ASGIApp, 72 | minimum_size: int = 500, 73 | compression: Optional[List[Compression]] = None, 74 | exclude_path: Optional[Set[str]] = None, 75 | exclude_mediatype: Optional[Set[str]] = None, 76 | compression_level: Optional[int] = None, 77 | ) -> None: 78 | """Init CompressionMiddleware. 79 | 80 | Args: 81 | app (ASGIApp): starlette/FastAPI application. 82 | minimum_size: Minimal size, in bytes, for appliying compression. Defaults to 500. 83 | compression (list): List of available compression backend. Order will define the backend preference. 84 | exclude_path (set): Set of regex expression to use to exclude compression for request path. Defaults to {}. 85 | exclude_mediatype (set): Set of media-type for which to exclude compression. Defaults to {}. 86 | compression_level (int): Compression level to use. 87 | 88 | """ 89 | self.app = app 90 | self.minimum_size = minimum_size 91 | self.exclude_path = {re.compile(p) for p in exclude_path or set()} 92 | self.exclude_mediatype = exclude_mediatype or set() 93 | self.compression = compression or DEFAULT_BACKENDS 94 | self.compression_level = compression_level 95 | 96 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 97 | """Handle call.""" 98 | if scope["type"] == "http": 99 | headers = Headers(scope=scope) 100 | accepted_encoding = headers.get("Accept-Encoding", "") 101 | 102 | if self.exclude_path: 103 | skip = any(x.fullmatch(scope["path"]) for x in self.exclude_path) 104 | else: 105 | skip = False 106 | 107 | backend = get_compression_backend(accepted_encoding, self.compression) 108 | if not skip and backend: 109 | if self.compression_level is not None: 110 | compressor = backend.compress.Compressor(level=self.compression_level) 111 | else: 112 | compressor = backend.compress.Compressor() 113 | 114 | responder = CompressionResponder( 115 | self.app, 116 | compressor, 117 | backend.name, 118 | self.minimum_size, 119 | self.exclude_mediatype, 120 | ) 121 | await responder(scope, receive, send) 122 | return 123 | 124 | await self.app(scope, receive, send) 125 | 126 | 127 | class CompressionResponder: 128 | """Responder class.""" 129 | 130 | def __init__( 131 | self, 132 | app: ASGIApp, 133 | compressor: Any, 134 | encoding_name: str, 135 | minimum_size: int, 136 | exclude_mediatype: Set[str], 137 | ) -> None: 138 | """Init.""" 139 | self.app = app 140 | self.compressor = compressor 141 | self.encoding_name = encoding_name 142 | self.minimum_size = minimum_size 143 | self.exclude_mediatype = exclude_mediatype 144 | self.send = unattached_send # type: Send 145 | self.initial_message = {} # type: Message 146 | self.started = False 147 | 148 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 149 | """Handle call.""" 150 | self.send = send 151 | await self.app(scope, receive, self.send_with_compression) 152 | 153 | async def send_with_compression(self, message: Message) -> None: 154 | """Compress response.""" 155 | message_type = message["type"] 156 | 157 | if message_type == "http.response.start": 158 | # Don't send the initial message until we've determined how to 159 | # modify the outgoing headers correctly. 160 | self.initial_message = message 161 | 162 | elif message_type == "http.response.body" and not self.started: 163 | self.started = True 164 | body = message.get("body", b"") 165 | more_body = message.get("more_body", False) 166 | 167 | headers = MutableHeaders(raw=self.initial_message["headers"]) 168 | 169 | if headers.get("Content-Type") in self.exclude_mediatype: 170 | # Don't apply compression if mediatype should be excluded 171 | await self.send(self.initial_message) 172 | await self.send(message) 173 | 174 | elif len(body) < self.minimum_size and not more_body: 175 | # Don't apply compression to small outgoing responses. 176 | await self.send(self.initial_message) 177 | await self.send(message) 178 | 179 | elif not more_body: 180 | # Standard compressed response. 181 | self.compressor.compress(body) 182 | body = self.compressor.finish() 183 | headers["Content-Encoding"] = self.encoding_name 184 | headers["Content-Length"] = str(body.len()) 185 | headers.add_vary_header("Accept-Encoding") 186 | message["body"] = bytes(body) 187 | await self.send(self.initial_message) 188 | await self.send(message) 189 | 190 | else: 191 | # Initial body in streaming compressed response. 192 | headers["Content-Encoding"] = self.encoding_name 193 | headers.add_vary_header("Accept-Encoding") 194 | 195 | # https://gist.github.com/CMCDragonkai/6bfade6431e9ffb7fe88#content-length 196 | # Content-Length header will not allow streaming 197 | del headers["Content-Length"] 198 | self.compressor.compress(body) 199 | message["body"] = bytes(self.compressor.flush()) 200 | 201 | await self.send(self.initial_message) 202 | await self.send(message) 203 | 204 | elif message_type == "http.response.body": 205 | # Remaining body in streaming compressed response. 206 | body = message.get("body", b"") 207 | more_body = message.get("more_body", False) 208 | 209 | self.compressor.compress(body) 210 | if not more_body: 211 | message["body"] = bytes(self.compressor.finish()) 212 | await self.send(message) 213 | return 214 | message["body"] = bytes(self.compressor.flush()) 215 | 216 | await self.send(message) 217 | 218 | 219 | async def unattached_send(message: Message) -> None: 220 | """Unable to send.""" 221 | raise RuntimeError("send awaitable not set") # pragma: no cover 222 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """starlette_cramjam tests suite.""" 2 | -------------------------------------------------------------------------------- /tests/test_middlewares.py: -------------------------------------------------------------------------------- 1 | """Main test for cramjam middleware. 2 | 3 | This tests are the same as the ones from starlette.tests.middleware.test_gzip but using multiple encoding. 4 | 5 | """ 6 | 7 | import sys 8 | 9 | import pytest 10 | from starlette.applications import Starlette 11 | from starlette.middleware import Middleware 12 | from starlette.responses import PlainTextResponse, Response, StreamingResponse 13 | from starlette.routing import Route 14 | from starlette.testclient import TestClient 15 | 16 | from starlette_cramjam.compression import Compression 17 | from starlette_cramjam.middleware import CompressionMiddleware, get_compression_backend 18 | 19 | available_schemes = ["br", "gzip", "deflate", "zstd"] 20 | 21 | 22 | @pytest.mark.parametrize("method", available_schemes) 23 | def test_compressed_responses(method): 24 | def homepage(request): 25 | return PlainTextResponse("x" * 4000, status_code=200) 26 | 27 | app = Starlette( 28 | routes=[Route("/", endpoint=homepage)], 29 | middleware=[ 30 | Middleware(CompressionMiddleware), 31 | ], 32 | ) 33 | 34 | client = TestClient(app) 35 | response = client.get("/", headers={"accept-encoding": method}) 36 | assert response.status_code == 200 37 | assert response.text == "x" * 4000 38 | assert response.headers["Content-Encoding"] == method 39 | assert sys.getsizeof(response.content) > int(response.headers["Content-Length"]) 40 | 41 | 42 | @pytest.mark.parametrize("method", available_schemes) 43 | def test_compression_level(method): 44 | def homepage(request): 45 | return PlainTextResponse("x" * 4000, status_code=200) 46 | 47 | app = Starlette( 48 | routes=[Route("/", endpoint=homepage)], 49 | middleware=[ 50 | Middleware(CompressionMiddleware, compression_level=3), 51 | ], 52 | ) 53 | 54 | client = TestClient(app) 55 | response = client.get("/", headers={"accept-encoding": method}) 56 | assert response.status_code == 200 57 | assert response.text == "x" * 4000 58 | assert response.headers["Content-Encoding"] == method 59 | assert sys.getsizeof(response.content) > int(response.headers["Content-Length"]) 60 | 61 | 62 | @pytest.mark.parametrize("method", available_schemes) 63 | def test_streaming_response(method): 64 | def homepage(request): 65 | async def generator(bytes, count): 66 | for _ in range(count): 67 | yield bytes 68 | 69 | streaming = generator(bytes=b"x" * 400, count=10) 70 | return StreamingResponse(streaming, status_code=200) 71 | 72 | app = Starlette( 73 | routes=[Route("/", endpoint=homepage)], 74 | middleware=[ 75 | Middleware(CompressionMiddleware, minimum_size=1), 76 | ], 77 | ) 78 | 79 | client = TestClient(app) 80 | 81 | response = client.get("/", headers={"accept-encoding": method}) 82 | assert response.status_code == 200 83 | assert response.headers["Content-Encoding"] == method 84 | assert "Content-Length" not in response.headers 85 | assert response.text == "x" * 4000 86 | 87 | 88 | def test_not_in_accept_encoding(): 89 | def homepage(request): 90 | return PlainTextResponse("x" * 4000, status_code=200) 91 | 92 | app = Starlette( 93 | routes=[Route("/", endpoint=homepage)], 94 | middleware=[ 95 | Middleware(CompressionMiddleware), 96 | ], 97 | ) 98 | 99 | client = TestClient(app) 100 | response = client.get("/", headers={"accept-encoding": "identity"}) 101 | assert response.status_code == 200 102 | assert response.text == "x" * 4000 103 | assert "Content-Encoding" not in response.headers 104 | assert int(response.headers["Content-Length"]) == 4000 105 | 106 | 107 | def test_ignored_for_small_responses(): 108 | def homepage(request): 109 | return PlainTextResponse("OK", status_code=200) 110 | 111 | app = Starlette( 112 | routes=[Route("/", endpoint=homepage)], 113 | middleware=[ 114 | Middleware(CompressionMiddleware), 115 | ], 116 | ) 117 | 118 | client = TestClient(app) 119 | response = client.get("/", headers={"accept-encoding": "gzip"}) 120 | assert response.status_code == 200 121 | assert response.text == "OK" 122 | assert "Content-Encoding" not in response.headers 123 | assert int(response.headers["Content-Length"]) == 2 124 | 125 | 126 | @pytest.mark.parametrize("method", available_schemes) 127 | def test_compressed_skip_on_content_type(method): 128 | def homepage(request): 129 | return Response(b"foo" * 1000, status_code=200, media_type="image/png") 130 | 131 | def foo(request): 132 | return Response(b"foo" * 1000, status_code=200, media_type="image/jpeg") 133 | 134 | app = Starlette( 135 | routes=[ 136 | Route("/", endpoint=homepage), 137 | Route("/foo", endpoint=foo), 138 | ], 139 | middleware=[ 140 | Middleware(CompressionMiddleware, exclude_mediatype={"image/png"}), 141 | ], 142 | ) 143 | 144 | client = TestClient(app) 145 | response = client.get("/", headers={"accept-encoding": method}) 146 | assert response.status_code == 200 147 | assert "Content-Encoding" not in response.headers 148 | 149 | response = client.get("/foo", headers={"accept-encoding": method}) 150 | assert response.status_code == 200 151 | assert response.headers["Content-Encoding"] == method 152 | 153 | 154 | @pytest.mark.parametrize("method", available_schemes) 155 | def test_compressed_skip_on_path(method): 156 | def homepage(request): 157 | return PlainTextResponse("yep", status_code=200) 158 | 159 | def foo(request): 160 | return Response("also yep but with /foo", status_code=200) 161 | 162 | def foo2(request): 163 | return Response("also yep but with /dontskip/foo", status_code=200) 164 | 165 | app = Starlette( 166 | routes=[ 167 | Route("/", endpoint=homepage), 168 | Route("/foo", endpoint=foo), 169 | Route("/dontskip/foo", endpoint=foo2), 170 | ], 171 | middleware=[ 172 | Middleware(CompressionMiddleware, exclude_path={"^/f.+"}, minimum_size=0), 173 | ], 174 | ) 175 | 176 | client = TestClient(app) 177 | response = client.get("/", headers={"accept-encoding": method}) 178 | assert response.status_code == 200 179 | assert response.headers["Content-Encoding"] == method 180 | 181 | response = client.get("/dontskip/foo", headers={"accept-encoding": method}) 182 | assert response.status_code == 200 183 | assert response.headers["Content-Encoding"] == method 184 | 185 | response = client.get("/foo", headers={"accept-encoding": method}) 186 | assert response.status_code == 200 187 | assert "Content-Encoding" not in response.headers 188 | 189 | 190 | @pytest.mark.parametrize( 191 | "compression,expected", 192 | [ 193 | ([], "gzip"), 194 | ([Compression.gzip], "gzip"), 195 | ([Compression.br, Compression.gzip], "br"), 196 | ([Compression.gzip, Compression.br], "gzip"), 197 | ], 198 | ) 199 | def test_compressed_skip_on_encoder(compression, expected): 200 | def homepage(request): 201 | return PlainTextResponse("x" * 4000, status_code=200) 202 | 203 | app = Starlette( 204 | routes=[ 205 | Route("/", endpoint=homepage), 206 | ], 207 | middleware=[ 208 | Middleware(CompressionMiddleware, compression=compression), 209 | ], 210 | ) 211 | 212 | client = TestClient(app) 213 | response = client.get("/", headers={"accept-encoding": "br,gzip,deflate"}) 214 | assert response.headers["Content-Encoding"] == expected 215 | 216 | 217 | @pytest.mark.parametrize( 218 | "compression,header,expected", 219 | [ 220 | # deflate preferred but only gzip available 221 | ([Compression.gzip], "deflate, gzip;q=0.8", Compression.gzip), 222 | # deflate preferred and available 223 | ( 224 | [Compression.gzip, Compression.deflate], 225 | "deflate, gzip;q=0.8", 226 | Compression.deflate, 227 | ), 228 | # asking for deflate or gzip but only br is available 229 | ([Compression.br], "deflate, gzip;q=0.8", None), 230 | # no accepted-encoding 231 | ([Compression.br], "", None), 232 | # br is prefered and available 233 | ( 234 | [Compression.gzip, Compression.br, Compression.deflate], 235 | "br;q=1.0, gzip;q=0.8", 236 | Compression.br, 237 | ), 238 | # br and gzip are equally preferred but gzip is the first available 239 | ([Compression.gzip, Compression.br], "br;q=1.0, gzip;q=1.0", Compression.gzip), 240 | # br and gzip are equally preferred but br is the first available 241 | ([Compression.br, Compression.gzip], "br;q=1.0, gzip;q=1.0", Compression.br), 242 | # br and gzip are available and client has no preference 243 | ([Compression.br, Compression.gzip], "*;q=1.0", Compression.br), 244 | # invalid br quality so ignored 245 | ([Compression.br, Compression.gzip], "br;q=aaa, gzip", Compression.gzip), 246 | # br quality is set to 0 247 | ([Compression.br, Compression.gzip], "br;q=0.0, gzip", Compression.gzip), 248 | ], 249 | ) 250 | def test_get_compression_backend(compression, header, expected): 251 | """Make sure we use the right compression.""" 252 | assert get_compression_backend(header, compression) == expected 253 | --------------------------------------------------------------------------------