starlette-compress 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
This is free and unencumbered software released into the public domain.
|
|
2
|
+
|
|
3
|
+
Anyone is free to copy, modify, publish, use, compile, sell, or
|
|
4
|
+
distribute this software, either in source code form or as a compiled
|
|
5
|
+
binary, for any purpose, commercial or non-commercial, and by any
|
|
6
|
+
means.
|
|
7
|
+
|
|
8
|
+
In jurisdictions that recognize copyright laws, the author or authors
|
|
9
|
+
of this software dedicate any and all copyright interest in the
|
|
10
|
+
software to the public domain. We make this dedication for the benefit
|
|
11
|
+
of the public at large and to the detriment of our heirs and
|
|
12
|
+
successors. We intend this dedication to be an overt act of
|
|
13
|
+
relinquishment in perpetuity of all present and future rights to this
|
|
14
|
+
software under copyright law.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
19
|
+
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
|
20
|
+
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
|
21
|
+
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
22
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
|
23
|
+
|
|
24
|
+
For more information, please refer to <https://unlicense.org>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: starlette-compress
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Compression middleware for Starlette - supporting ZStd, Brotli, and GZip
|
|
5
|
+
License: Unlicense
|
|
6
|
+
Author: Kamil Monicz
|
|
7
|
+
Author-email: kamil@monicz.dev
|
|
8
|
+
Requires-Python: >=3.8,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Requires-Dist: brotli (>=1) ; platform_python_implementation == "CPython"
|
|
17
|
+
Requires-Dist: brotlicffi (>=1) ; platform_python_implementation != "CPython"
|
|
18
|
+
Requires-Dist: starlette
|
|
19
|
+
Requires-Dist: zstandard (>=0.15)
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
|
|
File without changes
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
authors = ["Kamil Monicz <kamil@monicz.dev>"]
|
|
3
|
+
description = "Compression middleware for Starlette - supporting ZStd, Brotli, and GZip"
|
|
4
|
+
license = "Unlicense"
|
|
5
|
+
name = "starlette-compress"
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
|
|
9
|
+
[tool.poetry.dependencies]
|
|
10
|
+
brotli = {version = ">=1", markers = "platform_python_implementation == 'CPython'"}
|
|
11
|
+
brotlicffi = {version = ">=1", markers = "platform_python_implementation != 'CPython'"}
|
|
12
|
+
python = "^3.8"
|
|
13
|
+
starlette = "*"
|
|
14
|
+
zstandard = ">=0.15"
|
|
15
|
+
|
|
16
|
+
[tool.poetry.group.test.dependencies]
|
|
17
|
+
httpx = "<1"
|
|
18
|
+
mypy = "^1.0.0"
|
|
19
|
+
pytest = "^8.0.0"
|
|
20
|
+
pytest-cov = "^5.0.0"
|
|
21
|
+
pytest-watcher = "<1"
|
|
22
|
+
trio = "<1"
|
|
23
|
+
uvloop = "<1"
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
build-backend = "poetry.core.masonry.api"
|
|
27
|
+
requires = ["poetry-core"]
|
|
28
|
+
|
|
29
|
+
[tool.ruff]
|
|
30
|
+
# Exclude a variety of commonly ignored directories.
|
|
31
|
+
exclude = [
|
|
32
|
+
".bzr",
|
|
33
|
+
".direnv",
|
|
34
|
+
".eggs",
|
|
35
|
+
".git",
|
|
36
|
+
".git-rewrite",
|
|
37
|
+
".hg",
|
|
38
|
+
".ipynb_checkpoints",
|
|
39
|
+
".mypy_cache",
|
|
40
|
+
".nox",
|
|
41
|
+
".pants.d",
|
|
42
|
+
".pyenv",
|
|
43
|
+
".pytest_cache",
|
|
44
|
+
".pytype",
|
|
45
|
+
".ruff_cache",
|
|
46
|
+
".svn",
|
|
47
|
+
".tox",
|
|
48
|
+
".venv",
|
|
49
|
+
".vscode",
|
|
50
|
+
"__pypackages__",
|
|
51
|
+
"_build",
|
|
52
|
+
"buck-out",
|
|
53
|
+
"build",
|
|
54
|
+
"dist",
|
|
55
|
+
"node_modules",
|
|
56
|
+
"site-packages",
|
|
57
|
+
"venv",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
indent-width = 4
|
|
61
|
+
line-length = 120
|
|
62
|
+
target-version = "py38"
|
|
63
|
+
|
|
64
|
+
[tool.ruff.lint]
|
|
65
|
+
# custom + https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
|
|
66
|
+
ignore = [
|
|
67
|
+
"ARG001", # unused-function-argument
|
|
68
|
+
"SIM108", # if-else-block-instead-of-if-exp
|
|
69
|
+
"W191", # tab-indentation
|
|
70
|
+
"E111", # indentation-with-invalid-multiple
|
|
71
|
+
"E114", # indentation-with-invalid-multiple-comment
|
|
72
|
+
"E117", # over-indented
|
|
73
|
+
"E501", # line-too-long
|
|
74
|
+
"D206", # indent-with-spaces
|
|
75
|
+
"D300", # triple-single-quotes
|
|
76
|
+
"Q000", # bad-quotes-inline-string
|
|
77
|
+
"Q001", # bad-quotes-multiline-string
|
|
78
|
+
"Q002", # bad-quotes-docstring
|
|
79
|
+
"Q003", # avoidable-escaped-quote
|
|
80
|
+
"COM812", # missing-trailing-comma
|
|
81
|
+
"COM819", # prohibited-trailing-comma
|
|
82
|
+
"ISC001", # single-line-implicit-string-concatenation
|
|
83
|
+
"ISC002", # multi-line-implicit-string-concatenation
|
|
84
|
+
"S101", # assert
|
|
85
|
+
"TRY003", # raise-vanilla-args
|
|
86
|
+
]
|
|
87
|
+
# see https://docs.astral.sh/ruff/rules/ for rules documentation
|
|
88
|
+
select = [
|
|
89
|
+
"A", # flake8-builtins
|
|
90
|
+
"ARG", # flake8-unused-arguments
|
|
91
|
+
"ASYNC", # flake8-async
|
|
92
|
+
"B", # flake8-bugbear
|
|
93
|
+
"C4", # flake8-comprehensions
|
|
94
|
+
"DTZ", # flake8-datetimez
|
|
95
|
+
"E4", # pycodestyle
|
|
96
|
+
"E7",
|
|
97
|
+
"E9",
|
|
98
|
+
"F", # pyflakes
|
|
99
|
+
"FA", # flake8-future-annotations
|
|
100
|
+
"FBT", # flake8-boolean-trap
|
|
101
|
+
"FLY", # flynt # "FURB", # refurb
|
|
102
|
+
"G", # flake8-logging-format
|
|
103
|
+
"I", # isort
|
|
104
|
+
"ICN", # flake8-import-conventions
|
|
105
|
+
"INT", # flake8-gettext
|
|
106
|
+
"ISC", # flake8-implicit-str-concat
|
|
107
|
+
"LOG", # flake8-logging
|
|
108
|
+
"N", # pep8-naming
|
|
109
|
+
"NPY", # numpy
|
|
110
|
+
"Q", # flake8-quotes
|
|
111
|
+
"PERF", # perflint
|
|
112
|
+
"PGH", # pygrep-hooks
|
|
113
|
+
"PIE", # flake8-pie
|
|
114
|
+
"PT", # flake8-pytest-style
|
|
115
|
+
"PTH", # flake8-use-pathlib
|
|
116
|
+
"PYI", # flake8-pyi
|
|
117
|
+
"RSE", # flake8-raise
|
|
118
|
+
"RUF", # ruff
|
|
119
|
+
"S", # flake8-bandit
|
|
120
|
+
"SIM", # flake8-simplify
|
|
121
|
+
"SLF", # flake8-self
|
|
122
|
+
"SLOT", # flake8-slots
|
|
123
|
+
"T10", # flake8-debugger
|
|
124
|
+
"T20", # flake8-print
|
|
125
|
+
"TCH", # flake8-type-checking
|
|
126
|
+
"TID", # flake8-tidy-imports
|
|
127
|
+
"TRY", # tryceratops
|
|
128
|
+
"UP", # pyupgrade
|
|
129
|
+
"W6",
|
|
130
|
+
"YTT", # flake8-2020
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
# Allow fix for all enabled rules (when `--fix`) is provided.
|
|
134
|
+
fixable = ["ALL"]
|
|
135
|
+
unfixable = []
|
|
136
|
+
|
|
137
|
+
# Allow unused variables when underscore-prefixed.
|
|
138
|
+
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
|
139
|
+
|
|
140
|
+
[tool.ruff.format]
|
|
141
|
+
indent-style = "space"
|
|
142
|
+
line-ending = "lf"
|
|
143
|
+
quote-style = "single"
|
|
144
|
+
skip-magic-trailing-comma = false
|
|
145
|
+
|
|
146
|
+
[tool.ruff.lint.flake8-quotes]
|
|
147
|
+
docstring-quotes = "double"
|
|
148
|
+
inline-quotes = "single"
|
|
149
|
+
multiline-quotes = "double"
|
|
150
|
+
|
|
151
|
+
[tool.ruff.lint.isort]
|
|
152
|
+
combine-as-imports = true
|
|
153
|
+
|
|
154
|
+
[tool.pytest.ini_options]
|
|
155
|
+
addopts = "-ra --quiet --disable-pytest-warnings"
|
|
156
|
+
minversion = "6.0"
|
|
157
|
+
testpaths = [
|
|
158
|
+
"tests",
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
[tool.mypy]
|
|
162
|
+
exclude = [
|
|
163
|
+
"tests",
|
|
164
|
+
]
|
|
165
|
+
ignore_missing_imports = true
|
|
166
|
+
python_version = "3.8"
|
|
167
|
+
strict = true
|
|
168
|
+
warn_no_return = true
|
|
169
|
+
warn_redundant_casts = true
|
|
170
|
+
warn_return_any = true
|
|
171
|
+
warn_unreachable = true
|
|
172
|
+
warn_unused_ignores = true
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import gzip
|
|
4
|
+
import platform
|
|
5
|
+
import re
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from io import BytesIO
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from starlette.datastructures import Headers, MutableHeaders
|
|
11
|
+
from zstandard import ZstdCompressor
|
|
12
|
+
|
|
13
|
+
if platform.python_implementation() == 'CPython':
|
|
14
|
+
try:
|
|
15
|
+
import brotli
|
|
16
|
+
except ModuleNotFoundError:
|
|
17
|
+
import brotlicffi as brotli
|
|
18
|
+
else:
|
|
19
|
+
try:
|
|
20
|
+
import brotlicffi as brotli
|
|
21
|
+
except ModuleNotFoundError:
|
|
22
|
+
import brotli
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
26
|
+
from zstandard import ZstdCompressionChunker
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CompressMiddleware:
|
|
30
|
+
"""
|
|
31
|
+
Compression middleware for Starlette - supporting ZStd, Brotli, and GZip.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
__slots__ = (
|
|
35
|
+
'app',
|
|
36
|
+
'_zstd',
|
|
37
|
+
'_brotli',
|
|
38
|
+
'_gzip',
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
app: ASGIApp
|
|
42
|
+
_zstd: _ZstdResponder | None
|
|
43
|
+
_brotli: _BrotliResponder | None
|
|
44
|
+
_gzip: _GZipResponder | None
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
app: ASGIApp,
|
|
49
|
+
*,
|
|
50
|
+
minimum_size: int = 500,
|
|
51
|
+
zstd: bool = True,
|
|
52
|
+
zstd_level: int = 4,
|
|
53
|
+
brotli: bool = True,
|
|
54
|
+
brotli_quality: int = 4,
|
|
55
|
+
gzip: bool = True,
|
|
56
|
+
gzip_level: int = 4,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.app = app
|
|
59
|
+
self._zstd = _ZstdResponder(app, minimum_size, ZstdCompressor(level=zstd_level)) if zstd else None
|
|
60
|
+
self._brotli = _BrotliResponder(app, minimum_size, brotli_quality) if brotli else None
|
|
61
|
+
self._gzip = _GZipResponder(app, minimum_size, gzip_level) if gzip else None
|
|
62
|
+
|
|
63
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
64
|
+
if scope['type'] != 'http':
|
|
65
|
+
await self.app(scope, receive, send)
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
accept_encoding = Headers(scope=scope).get('Accept-Encoding')
|
|
69
|
+
|
|
70
|
+
if not accept_encoding:
|
|
71
|
+
await self.app(scope, receive, send)
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
accept_encodings = _parse_accept_encoding(accept_encoding)
|
|
75
|
+
|
|
76
|
+
if (self._zstd is not None) and 'zstd' in accept_encodings:
|
|
77
|
+
await self._zstd(scope, receive, send)
|
|
78
|
+
elif (self._brotli is not None) and 'br' in accept_encodings:
|
|
79
|
+
await self._brotli(scope, receive, send)
|
|
80
|
+
elif (self._gzip is not None) and 'gzip' in accept_encodings:
|
|
81
|
+
await self._gzip(scope, receive, send)
|
|
82
|
+
else:
|
|
83
|
+
await self.app(scope, receive, send)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _ZstdResponder:
|
|
87
|
+
__slots__ = (
|
|
88
|
+
'app',
|
|
89
|
+
'minimum_size',
|
|
90
|
+
'compressor',
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def __init__(self, app: ASGIApp, minimum_size: int, compressor: ZstdCompressor) -> None:
|
|
94
|
+
self.app = app
|
|
95
|
+
self.minimum_size = minimum_size
|
|
96
|
+
self.compressor = compressor
|
|
97
|
+
|
|
98
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
99
|
+
start_message: Message | None = None
|
|
100
|
+
chunker: ZstdCompressionChunker | None = None
|
|
101
|
+
|
|
102
|
+
async def wrapper(message: Message) -> None:
|
|
103
|
+
nonlocal start_message, chunker
|
|
104
|
+
|
|
105
|
+
message_type: str = message['type']
|
|
106
|
+
|
|
107
|
+
# handle start message
|
|
108
|
+
if message_type == 'http.response.start':
|
|
109
|
+
if start_message is not None:
|
|
110
|
+
raise AssertionError('Unexpected repeated http.response.start message')
|
|
111
|
+
|
|
112
|
+
if _is_start_message_satisfied(message):
|
|
113
|
+
# capture start message and wait for response body
|
|
114
|
+
start_message = message
|
|
115
|
+
return
|
|
116
|
+
else:
|
|
117
|
+
await send(message)
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
# skip if start message is not satisfied or unknown message type
|
|
121
|
+
if start_message is None or message_type != 'http.response.body':
|
|
122
|
+
await send(message)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
body: bytes = message.get('body', b'')
|
|
126
|
+
more_body: bool = message.get('more_body', False)
|
|
127
|
+
|
|
128
|
+
if chunker is None:
|
|
129
|
+
# skip compression for small responses
|
|
130
|
+
if not more_body and len(body) < self.minimum_size:
|
|
131
|
+
await send(start_message)
|
|
132
|
+
await send(message)
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
headers = MutableHeaders(raw=start_message['headers'])
|
|
136
|
+
headers['Content-Encoding'] = 'zstd'
|
|
137
|
+
headers.add_vary_header('Accept-Encoding')
|
|
138
|
+
|
|
139
|
+
if not more_body:
|
|
140
|
+
# one-shot
|
|
141
|
+
compressed_body = self.compressor.compress(body)
|
|
142
|
+
headers['Content-Length'] = str(len(compressed_body))
|
|
143
|
+
message['body'] = compressed_body
|
|
144
|
+
await send(start_message)
|
|
145
|
+
await send(message)
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
# begin streaming
|
|
149
|
+
content_length: int = int(headers.get('Content-Length', -1))
|
|
150
|
+
del headers['Content-Length']
|
|
151
|
+
await send(start_message)
|
|
152
|
+
chunker = self.compressor.chunker(content_length)
|
|
153
|
+
|
|
154
|
+
# streaming
|
|
155
|
+
for chunk in chunker.compress(body):
|
|
156
|
+
await send({'type': 'http.response.body', 'body': chunk, 'more_body': True})
|
|
157
|
+
if more_body:
|
|
158
|
+
return
|
|
159
|
+
for chunk in chunker.finish(): # type: ignore[no-untyped-call]
|
|
160
|
+
await send({'type': 'http.response.body', 'body': chunk, 'more_body': True})
|
|
161
|
+
|
|
162
|
+
await send({'type': 'http.response.body'})
|
|
163
|
+
|
|
164
|
+
await self.app(scope, receive, wrapper)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _BrotliResponder:
|
|
168
|
+
__slots__ = (
|
|
169
|
+
'app',
|
|
170
|
+
'minimum_size',
|
|
171
|
+
'quality',
|
|
172
|
+
'compressor',
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def __init__(self, app: ASGIApp, minimum_size: int, quality: int) -> None:
|
|
176
|
+
self.app = app
|
|
177
|
+
self.minimum_size = minimum_size
|
|
178
|
+
self.quality = quality
|
|
179
|
+
|
|
180
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
181
|
+
start_message: Message | None = None
|
|
182
|
+
compressor: brotli.Compressor | None = None
|
|
183
|
+
|
|
184
|
+
async def wrapper(message: Message) -> None:
|
|
185
|
+
nonlocal start_message, compressor
|
|
186
|
+
|
|
187
|
+
message_type: str = message['type']
|
|
188
|
+
|
|
189
|
+
# handle start message
|
|
190
|
+
if message_type == 'http.response.start':
|
|
191
|
+
if start_message is not None:
|
|
192
|
+
raise AssertionError('Unexpected repeated http.response.start message')
|
|
193
|
+
|
|
194
|
+
if _is_start_message_satisfied(message):
|
|
195
|
+
# capture start message and wait for response body
|
|
196
|
+
start_message = message
|
|
197
|
+
return
|
|
198
|
+
else:
|
|
199
|
+
await send(message)
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
# skip if start message is not satisfied or unknown message type
|
|
203
|
+
if start_message is None or message_type != 'http.response.body':
|
|
204
|
+
await send(message)
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
body: bytes = message.get('body', b'')
|
|
208
|
+
more_body: bool = message.get('more_body', False)
|
|
209
|
+
|
|
210
|
+
if compressor is None:
|
|
211
|
+
# skip compression for small responses
|
|
212
|
+
if not more_body and len(body) < self.minimum_size:
|
|
213
|
+
await send(start_message)
|
|
214
|
+
await send(message)
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
headers = MutableHeaders(raw=start_message['headers'])
|
|
218
|
+
headers['Content-Encoding'] = 'br'
|
|
219
|
+
headers.add_vary_header('Accept-Encoding')
|
|
220
|
+
|
|
221
|
+
if not more_body:
|
|
222
|
+
# one-shot
|
|
223
|
+
compressed_body = brotli.compress(body, quality=self.quality)
|
|
224
|
+
headers['Content-Length'] = str(len(compressed_body))
|
|
225
|
+
message['body'] = compressed_body
|
|
226
|
+
await send(start_message)
|
|
227
|
+
await send(message)
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
# begin streaming
|
|
231
|
+
del headers['Content-Length']
|
|
232
|
+
await send(start_message)
|
|
233
|
+
compressor = brotli.Compressor(quality=self.quality)
|
|
234
|
+
|
|
235
|
+
# streaming
|
|
236
|
+
chunk = compressor.process(body)
|
|
237
|
+
if chunk:
|
|
238
|
+
await send({'type': 'http.response.body', 'body': chunk, 'more_body': True})
|
|
239
|
+
if more_body:
|
|
240
|
+
return
|
|
241
|
+
chunk = compressor.finish()
|
|
242
|
+
await send({'type': 'http.response.body', 'body': chunk})
|
|
243
|
+
|
|
244
|
+
await self.app(scope, receive, wrapper)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class _GZipResponder:
|
|
248
|
+
__slots__ = (
|
|
249
|
+
'app',
|
|
250
|
+
'minimum_size',
|
|
251
|
+
'level',
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def __init__(self, app: ASGIApp, minimum_size: int, level: int) -> None:
|
|
255
|
+
self.app = app
|
|
256
|
+
self.minimum_size = minimum_size
|
|
257
|
+
self.level = level
|
|
258
|
+
|
|
259
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
260
|
+
start_message: Message | None = None
|
|
261
|
+
compressor: gzip.GzipFile | None = None
|
|
262
|
+
buffer: BytesIO | None = None
|
|
263
|
+
|
|
264
|
+
async def wrapper(message: Message) -> None:
|
|
265
|
+
nonlocal start_message, compressor, buffer
|
|
266
|
+
|
|
267
|
+
message_type: str = message['type']
|
|
268
|
+
|
|
269
|
+
# handle start message
|
|
270
|
+
if message_type == 'http.response.start':
|
|
271
|
+
if start_message is not None:
|
|
272
|
+
raise AssertionError('Unexpected repeated http.response.start message')
|
|
273
|
+
|
|
274
|
+
if _is_start_message_satisfied(message):
|
|
275
|
+
# capture start message and wait for response body
|
|
276
|
+
start_message = message
|
|
277
|
+
return
|
|
278
|
+
else:
|
|
279
|
+
await send(message)
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
# skip if start message is not satisfied or unknown message type
|
|
283
|
+
if start_message is None or message_type != 'http.response.body':
|
|
284
|
+
await send(message)
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
body: bytes = message.get('body', b'')
|
|
288
|
+
more_body: bool = message.get('more_body', False)
|
|
289
|
+
|
|
290
|
+
if compressor is None:
|
|
291
|
+
# skip compression for small responses
|
|
292
|
+
if not more_body and len(body) < self.minimum_size:
|
|
293
|
+
await send(start_message)
|
|
294
|
+
await send(message)
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
headers = MutableHeaders(raw=start_message['headers'])
|
|
298
|
+
headers['Content-Encoding'] = 'gzip'
|
|
299
|
+
headers.add_vary_header('Accept-Encoding')
|
|
300
|
+
|
|
301
|
+
if not more_body:
|
|
302
|
+
# one-shot
|
|
303
|
+
compressed_body = gzip.compress(body, compresslevel=self.level)
|
|
304
|
+
headers['Content-Length'] = str(len(compressed_body))
|
|
305
|
+
message['body'] = compressed_body
|
|
306
|
+
await send(start_message)
|
|
307
|
+
await send(message)
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
# begin streaming
|
|
311
|
+
del headers['Content-Length']
|
|
312
|
+
await send(start_message)
|
|
313
|
+
buffer = BytesIO()
|
|
314
|
+
compressor = gzip.GzipFile(mode='wb', compresslevel=self.level, fileobj=buffer)
|
|
315
|
+
|
|
316
|
+
if buffer is None:
|
|
317
|
+
raise AssertionError('Compressor is set but buffer is not')
|
|
318
|
+
|
|
319
|
+
# streaming
|
|
320
|
+
compressor.write(body)
|
|
321
|
+
if not more_body:
|
|
322
|
+
compressor.close()
|
|
323
|
+
compressed_body = buffer.getvalue()
|
|
324
|
+
if more_body:
|
|
325
|
+
if compressed_body:
|
|
326
|
+
buffer.seek(0)
|
|
327
|
+
buffer.truncate()
|
|
328
|
+
else:
|
|
329
|
+
return
|
|
330
|
+
await send(
|
|
331
|
+
{
|
|
332
|
+
'type': 'http.response.body',
|
|
333
|
+
'body': compressed_body,
|
|
334
|
+
'more_body': more_body,
|
|
335
|
+
}
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
await self.app(scope, receive, wrapper)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
_accept_encoding_re = re.compile(r'[a-z]{2,8}')
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@lru_cache(maxsize=128)
|
|
345
|
+
def _parse_accept_encoding(accept_encoding: str) -> frozenset[str]:
|
|
346
|
+
"""
|
|
347
|
+
Parse the accept encoding header and return a set of supported encodings.
|
|
348
|
+
|
|
349
|
+
>>> parse_accept_encoding('br;q=1.0, gzip;q=0.8, *;q=0.1')
|
|
350
|
+
{'br', 'gzip'}
|
|
351
|
+
"""
|
|
352
|
+
return frozenset(_accept_encoding_re.findall(accept_encoding))
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# Mostly based on https://github.com/h5bp/server-configs-nginx/blob/main/h5bp/web_performance/compression.conf#L38
|
|
356
|
+
_compress_content_types: set[str] = {
|
|
357
|
+
'application/atom+xml',
|
|
358
|
+
'application/geo+json',
|
|
359
|
+
'application/gpx+xml',
|
|
360
|
+
'application/javascript',
|
|
361
|
+
'application/x-javascript',
|
|
362
|
+
'application/json',
|
|
363
|
+
'application/ld+json',
|
|
364
|
+
'application/manifest+json',
|
|
365
|
+
'application/rdf+xml',
|
|
366
|
+
'application/rss+xml',
|
|
367
|
+
'application/vnd.mapbox-vector-tile',
|
|
368
|
+
'application/vnd.ms-fontobject',
|
|
369
|
+
'application/wasm',
|
|
370
|
+
'application/x-web-app-manifest+json',
|
|
371
|
+
'application/xhtml+xml',
|
|
372
|
+
'application/xml',
|
|
373
|
+
'font/eot',
|
|
374
|
+
'font/otf',
|
|
375
|
+
'font/ttf',
|
|
376
|
+
'image/bmp',
|
|
377
|
+
'image/svg+xml',
|
|
378
|
+
'image/vnd.microsoft.icon',
|
|
379
|
+
'image/x-icon',
|
|
380
|
+
'text/cache-manifest',
|
|
381
|
+
'text/calendar',
|
|
382
|
+
'text/css',
|
|
383
|
+
'text/html',
|
|
384
|
+
'text/javascript',
|
|
385
|
+
'text/markdown',
|
|
386
|
+
'text/plain',
|
|
387
|
+
'text/xml',
|
|
388
|
+
'text/vcard',
|
|
389
|
+
'text/vnd.rim.location.xloc',
|
|
390
|
+
'text/vtt',
|
|
391
|
+
'text/x-component',
|
|
392
|
+
'text/x-cross-domain-policy',
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def register_compress_content_type(content_type: str) -> None:
|
|
397
|
+
"""
|
|
398
|
+
Register a new content type to be compressed.
|
|
399
|
+
"""
|
|
400
|
+
_compress_content_types.add(content_type)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def deregister_compress_content_type(content_type: str) -> None:
|
|
404
|
+
"""
|
|
405
|
+
Deregister a content type from being compressed.
|
|
406
|
+
"""
|
|
407
|
+
_compress_content_types.discard(content_type)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _is_start_message_satisfied(message: Message) -> bool:
|
|
411
|
+
"""
|
|
412
|
+
Check if response should be compressed based on the start message.
|
|
413
|
+
"""
|
|
414
|
+
headers = Headers(raw=message['headers'])
|
|
415
|
+
|
|
416
|
+
# must not already be compressed
|
|
417
|
+
if 'Content-Encoding' in headers:
|
|
418
|
+
return False
|
|
419
|
+
|
|
420
|
+
# content type header must be present
|
|
421
|
+
content_type = headers.get('Content-Type')
|
|
422
|
+
if not content_type:
|
|
423
|
+
return False
|
|
424
|
+
|
|
425
|
+
# must be a compressible content type
|
|
426
|
+
basic_content_type = content_type.partition(';')[0].strip()
|
|
427
|
+
return basic_content_type in _compress_content_types
|