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