├── .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 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
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 |
--------------------------------------------------------------------------------