asgi-compression 0.1.0__py3-none-any.whl
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.
- asgi_compression/__init__.py +17 -0
- asgi_compression/base.py +140 -0
- asgi_compression/brotli.py +101 -0
- asgi_compression/gzip.py +61 -0
- asgi_compression/identity.py +23 -0
- asgi_compression/middleware.py +82 -0
- asgi_compression/types.py +63 -0
- asgi_compression/zstd.py +92 -0
- asgi_compression-0.1.0.dist-info/METADATA +14 -0
- asgi_compression-0.1.0.dist-info/RECORD +12 -0
- asgi_compression-0.1.0.dist-info/WHEEL +5 -0
- asgi_compression-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,17 @@
|
|
1
|
+
from .base import CompressionAlgorithm, ContentEncoding
|
2
|
+
from .brotli import BrotliAlgorithm, BrotliMode
|
3
|
+
from .gzip import GzipAlgorithm
|
4
|
+
from .identity import IdentityAlgorithm
|
5
|
+
from .middleware import CompressionMiddleware
|
6
|
+
from .zstd import ZstdAlgorithm
|
7
|
+
|
8
|
+
__all__ = [
|
9
|
+
"CompressionMiddleware",
|
10
|
+
"CompressionAlgorithm",
|
11
|
+
"ContentEncoding",
|
12
|
+
"GzipAlgorithm",
|
13
|
+
"BrotliAlgorithm",
|
14
|
+
"BrotliMode",
|
15
|
+
"IdentityAlgorithm",
|
16
|
+
"ZstdAlgorithm",
|
17
|
+
]
|
asgi_compression/base.py
ADDED
@@ -0,0 +1,140 @@
|
|
1
|
+
import typing
|
2
|
+
from abc import ABC, abstractmethod
|
3
|
+
from dataclasses import dataclass
|
4
|
+
from enum import Enum
|
5
|
+
|
6
|
+
from .types import ASGIApp, Headers, Message, Receive, Scope, Send
|
7
|
+
|
8
|
+
DEFAULT_EXCLUDED_CONTENT_TYPES = ("text/event-stream",)
|
9
|
+
DEFAULT_MINIMUM_SIZE = 500
|
10
|
+
|
11
|
+
|
12
|
+
class ContentEncoding(str, Enum):
|
13
|
+
GZIP = "gzip"
|
14
|
+
BROTLI = "br"
|
15
|
+
ZSTD = "zstd"
|
16
|
+
IDENTITY = "identity"
|
17
|
+
|
18
|
+
|
19
|
+
async def unattached_send(message: Message) -> typing.NoReturn:
|
20
|
+
raise RuntimeError("send awaitable not set") # pragma: no cover
|
21
|
+
|
22
|
+
|
23
|
+
class CompressionResponder(ABC):
|
24
|
+
"""Base class for all compression responders."""
|
25
|
+
|
26
|
+
content_encoding: ContentEncoding
|
27
|
+
|
28
|
+
def __init__(self, app: ASGIApp, minimum_size: int) -> None:
|
29
|
+
self.app = app
|
30
|
+
self.minimum_size = minimum_size
|
31
|
+
self._send: Send = unattached_send
|
32
|
+
self._initial_message: Message = {}
|
33
|
+
self._started = False
|
34
|
+
self._content_encoding_set = False
|
35
|
+
self._content_type_is_excluded = False
|
36
|
+
|
37
|
+
async def __call__(
|
38
|
+
self,
|
39
|
+
scope: Scope,
|
40
|
+
receive: Receive,
|
41
|
+
send: Send,
|
42
|
+
) -> None:
|
43
|
+
self._send = send
|
44
|
+
await self.app(scope, receive, self.send_with_compression)
|
45
|
+
|
46
|
+
async def send_with_compression(self, message: Message) -> None:
|
47
|
+
message_type = message["type"]
|
48
|
+
if message_type == "http.response.start":
|
49
|
+
# Don't send the initial message until we've determined how to
|
50
|
+
# modify the outgoing headers correctly.
|
51
|
+
self._initial_message = message
|
52
|
+
headers = Headers(raw=self._initial_message["headers"])
|
53
|
+
|
54
|
+
self._content_encoding_set = "content-encoding" in headers
|
55
|
+
self._content_type_is_excluded = headers.get(
|
56
|
+
"content-type", ""
|
57
|
+
).startswith(DEFAULT_EXCLUDED_CONTENT_TYPES)
|
58
|
+
|
59
|
+
elif message_type == "http.response.body" and (
|
60
|
+
self._content_encoding_set or self._content_type_is_excluded
|
61
|
+
):
|
62
|
+
if not self._started:
|
63
|
+
self._started = True
|
64
|
+
await self._send(self._initial_message)
|
65
|
+
await self._send(message)
|
66
|
+
|
67
|
+
elif message_type == "http.response.body" and not self._started:
|
68
|
+
self._started = True
|
69
|
+
body = message.get("body", b"")
|
70
|
+
more_body = message.get("more_body", False)
|
71
|
+
|
72
|
+
if len(body) < self.minimum_size and not more_body:
|
73
|
+
# Don't apply compression to small outgoing responses.
|
74
|
+
# Don't add Vary header for small responses
|
75
|
+
await self._send(self._initial_message)
|
76
|
+
await self._send(message)
|
77
|
+
elif not more_body:
|
78
|
+
# Standard response.
|
79
|
+
body = self.apply_compression(body, more_body=False)
|
80
|
+
|
81
|
+
headers = Headers(raw=self._initial_message["headers"])
|
82
|
+
headers.add_vary_header("Accept-Encoding")
|
83
|
+
|
84
|
+
if body != message["body"]:
|
85
|
+
headers["Content-Encoding"] = self.content_encoding
|
86
|
+
headers["Content-Length"] = str(len(body))
|
87
|
+
message["body"] = body
|
88
|
+
|
89
|
+
self._initial_message["headers"] = headers.encode()
|
90
|
+
await self._send(self._initial_message)
|
91
|
+
await self._send(message)
|
92
|
+
else:
|
93
|
+
# Initial body in streaming response.
|
94
|
+
body = self.apply_compression(body, more_body=True)
|
95
|
+
|
96
|
+
headers = Headers(raw=self._initial_message["headers"])
|
97
|
+
headers.add_vary_header("Accept-Encoding")
|
98
|
+
|
99
|
+
if body != message["body"]:
|
100
|
+
headers["Content-Encoding"] = self.content_encoding
|
101
|
+
if "Content-Length" in headers:
|
102
|
+
del headers["Content-Length"]
|
103
|
+
|
104
|
+
message["body"] = body
|
105
|
+
|
106
|
+
self._initial_message["headers"] = headers.encode()
|
107
|
+
await self._send(self._initial_message)
|
108
|
+
await self._send(message)
|
109
|
+
elif message_type == "http.response.body": # pragma: no branch
|
110
|
+
# Remaining body in streaming response.
|
111
|
+
body = message.get("body", b"")
|
112
|
+
more_body = message.get("more_body", False)
|
113
|
+
|
114
|
+
message["body"] = self.apply_compression(body, more_body=more_body)
|
115
|
+
await self._send(message)
|
116
|
+
|
117
|
+
@abstractmethod
|
118
|
+
def apply_compression(self, body: bytes, *, more_body: bool) -> bytes:
|
119
|
+
"""Apply compression on the response body.
|
120
|
+
|
121
|
+
If more_body is False, any compression file should be closed. If it
|
122
|
+
isn't, it won't be closed automatically until all background tasks
|
123
|
+
complete.
|
124
|
+
"""
|
125
|
+
raise NotImplementedError
|
126
|
+
|
127
|
+
|
128
|
+
@dataclass
|
129
|
+
class CompressionAlgorithm(ABC):
|
130
|
+
"""Base class for compression algorithms."""
|
131
|
+
|
132
|
+
type: ContentEncoding
|
133
|
+
minimum_size: int = DEFAULT_MINIMUM_SIZE
|
134
|
+
|
135
|
+
def create_responder(self, app: ASGIApp) -> "CompressionResponder":
|
136
|
+
"""Create a responder for this compression algorithm."""
|
137
|
+
raise NotImplementedError
|
138
|
+
|
139
|
+
def check_available(self) -> None:
|
140
|
+
"""Check if the algorithm is available in the current environment."""
|
@@ -0,0 +1,101 @@
|
|
1
|
+
import io
|
2
|
+
from dataclasses import dataclass
|
3
|
+
from enum import Enum
|
4
|
+
from typing import TYPE_CHECKING
|
5
|
+
|
6
|
+
from .base import CompressionAlgorithm, CompressionResponder, ContentEncoding
|
7
|
+
from .types import ASGIApp
|
8
|
+
|
9
|
+
if TYPE_CHECKING:
|
10
|
+
import brotli
|
11
|
+
|
12
|
+
|
13
|
+
def import_brotli() -> None:
|
14
|
+
global brotli
|
15
|
+
try:
|
16
|
+
import brotli
|
17
|
+
except ImportError as e:
|
18
|
+
raise ImportError(
|
19
|
+
"brotli is not installed, run `pip install brotli`]"
|
20
|
+
) from e
|
21
|
+
|
22
|
+
|
23
|
+
class BrotliMode(Enum):
|
24
|
+
TEXT = "text"
|
25
|
+
FONT = "font"
|
26
|
+
GENERIC = "generic"
|
27
|
+
|
28
|
+
def to_brotli_mode(self) -> int:
|
29
|
+
if self == BrotliMode.TEXT:
|
30
|
+
return brotli.MODE_TEXT
|
31
|
+
elif self == BrotliMode.FONT:
|
32
|
+
return brotli.MODE_FONT
|
33
|
+
elif self == BrotliMode.GENERIC:
|
34
|
+
return brotli.MODE_GENERIC
|
35
|
+
else:
|
36
|
+
assert False, f"Expected code to be unreachable, but got: {self}"
|
37
|
+
|
38
|
+
|
39
|
+
class BrotliResponder(CompressionResponder):
|
40
|
+
"""Responder that applies brotli compression."""
|
41
|
+
|
42
|
+
content_encoding = ContentEncoding.BROTLI
|
43
|
+
|
44
|
+
def __init__(
|
45
|
+
self,
|
46
|
+
app: ASGIApp,
|
47
|
+
minimum_size: int,
|
48
|
+
quality: int = 4,
|
49
|
+
mode: BrotliMode = BrotliMode.TEXT,
|
50
|
+
lgwin: int = 22,
|
51
|
+
lgblock: int = 0,
|
52
|
+
) -> None:
|
53
|
+
super().__init__(app, minimum_size)
|
54
|
+
|
55
|
+
import_brotli()
|
56
|
+
|
57
|
+
self.brotli_buffer = io.BytesIO()
|
58
|
+
self.compressor = brotli.Compressor(
|
59
|
+
quality=quality,
|
60
|
+
mode=mode.to_brotli_mode(),
|
61
|
+
lgwin=lgwin,
|
62
|
+
lgblock=lgblock,
|
63
|
+
)
|
64
|
+
|
65
|
+
def apply_compression(self, body: bytes, *, more_body: bool) -> bytes:
|
66
|
+
compressed = self.compressor.process(body)
|
67
|
+
self.brotli_buffer.write(compressed)
|
68
|
+
|
69
|
+
if not more_body:
|
70
|
+
final_data = self.compressor.finish()
|
71
|
+
self.brotli_buffer.write(final_data)
|
72
|
+
|
73
|
+
compressed_data = self.brotli_buffer.getvalue()
|
74
|
+
|
75
|
+
self.brotli_buffer.seek(0)
|
76
|
+
self.brotli_buffer.truncate()
|
77
|
+
return compressed_data
|
78
|
+
|
79
|
+
|
80
|
+
@dataclass
|
81
|
+
class BrotliAlgorithm(CompressionAlgorithm):
|
82
|
+
"""Brotli compression algorithm."""
|
83
|
+
|
84
|
+
type: ContentEncoding = ContentEncoding.BROTLI
|
85
|
+
quality: int = 4
|
86
|
+
mode: BrotliMode = BrotliMode.TEXT
|
87
|
+
lgwin: int = 22
|
88
|
+
lgblock: int = 0
|
89
|
+
|
90
|
+
def create_responder(self, app: ASGIApp) -> "BrotliResponder":
|
91
|
+
return BrotliResponder(
|
92
|
+
app=app,
|
93
|
+
minimum_size=self.minimum_size,
|
94
|
+
quality=self.quality,
|
95
|
+
mode=self.mode,
|
96
|
+
lgwin=self.lgwin,
|
97
|
+
lgblock=self.lgblock,
|
98
|
+
)
|
99
|
+
|
100
|
+
def check_available(self) -> None:
|
101
|
+
import_brotli()
|
asgi_compression/gzip.py
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
import gzip
|
2
|
+
import io
|
3
|
+
from dataclasses import dataclass
|
4
|
+
|
5
|
+
from .base import CompressionAlgorithm, CompressionResponder, ContentEncoding
|
6
|
+
from .types import ASGIApp, Receive, Scope, Send
|
7
|
+
|
8
|
+
|
9
|
+
class GzipResponder(CompressionResponder):
|
10
|
+
"""Responder that applies gzip compression."""
|
11
|
+
|
12
|
+
content_encoding = ContentEncoding.GZIP
|
13
|
+
|
14
|
+
def __init__(
|
15
|
+
self,
|
16
|
+
app: ASGIApp,
|
17
|
+
minimum_size: int,
|
18
|
+
compresslevel: int = 9,
|
19
|
+
) -> None:
|
20
|
+
super().__init__(app, minimum_size)
|
21
|
+
|
22
|
+
self.gzip_buffer = io.BytesIO()
|
23
|
+
self.gzip_file = gzip.GzipFile(
|
24
|
+
mode="wb",
|
25
|
+
fileobj=self.gzip_buffer,
|
26
|
+
compresslevel=compresslevel,
|
27
|
+
)
|
28
|
+
|
29
|
+
async def __call__(
|
30
|
+
self,
|
31
|
+
scope: Scope,
|
32
|
+
receive: Receive,
|
33
|
+
send: Send,
|
34
|
+
) -> None:
|
35
|
+
with self.gzip_buffer, self.gzip_file:
|
36
|
+
await super().__call__(scope, receive, send)
|
37
|
+
|
38
|
+
def apply_compression(self, body: bytes, *, more_body: bool) -> bytes:
|
39
|
+
self.gzip_file.write(body)
|
40
|
+
if not more_body:
|
41
|
+
self.gzip_file.close()
|
42
|
+
|
43
|
+
body = self.gzip_buffer.getvalue()
|
44
|
+
self.gzip_buffer.seek(0)
|
45
|
+
self.gzip_buffer.truncate()
|
46
|
+
return body
|
47
|
+
|
48
|
+
|
49
|
+
@dataclass
|
50
|
+
class GzipAlgorithm(CompressionAlgorithm):
|
51
|
+
"""Gzip compression algorithm."""
|
52
|
+
|
53
|
+
type: ContentEncoding = ContentEncoding.GZIP
|
54
|
+
compresslevel: int = 9
|
55
|
+
|
56
|
+
def create_responder(self, app: ASGIApp) -> GzipResponder:
|
57
|
+
return GzipResponder(
|
58
|
+
app=app,
|
59
|
+
minimum_size=self.minimum_size,
|
60
|
+
compresslevel=self.compresslevel,
|
61
|
+
)
|
@@ -0,0 +1,23 @@
|
|
1
|
+
from dataclasses import dataclass
|
2
|
+
|
3
|
+
from .base import CompressionAlgorithm, CompressionResponder, ContentEncoding
|
4
|
+
from .types import ASGIApp
|
5
|
+
|
6
|
+
|
7
|
+
class IdentityResponder(CompressionResponder):
|
8
|
+
"""Responder that doesn't apply any compression."""
|
9
|
+
|
10
|
+
content_encoding = ContentEncoding.IDENTITY
|
11
|
+
|
12
|
+
def apply_compression(self, body: bytes, *, more_body: bool) -> bytes:
|
13
|
+
return body
|
14
|
+
|
15
|
+
|
16
|
+
@dataclass
|
17
|
+
class IdentityAlgorithm(CompressionAlgorithm):
|
18
|
+
"""No compression, identity algorithm."""
|
19
|
+
|
20
|
+
type: ContentEncoding = ContentEncoding.IDENTITY
|
21
|
+
|
22
|
+
def create_responder(self, app: ASGIApp) -> IdentityResponder:
|
23
|
+
return IdentityResponder(app=app, minimum_size=self.minimum_size)
|
@@ -0,0 +1,82 @@
|
|
1
|
+
from typing import List, Optional, Union
|
2
|
+
|
3
|
+
from .base import (
|
4
|
+
DEFAULT_MINIMUM_SIZE,
|
5
|
+
CompressionAlgorithm,
|
6
|
+
CompressionResponder,
|
7
|
+
)
|
8
|
+
from .identity import IdentityAlgorithm
|
9
|
+
from .types import ASGIApp, Headers, Receive, Scope, Send
|
10
|
+
|
11
|
+
|
12
|
+
class CompressionMiddleware:
|
13
|
+
"""
|
14
|
+
Unified ASGI middleware for response compression.
|
15
|
+
|
16
|
+
Supports multiple compression algorithms and automatically negotiates
|
17
|
+
the best available algorithm based on the client's Accept-Encoding header.
|
18
|
+
"""
|
19
|
+
|
20
|
+
def __init__(
|
21
|
+
self,
|
22
|
+
app: ASGIApp,
|
23
|
+
algorithms: Optional[List[CompressionAlgorithm]] = None,
|
24
|
+
minimum_size: int = DEFAULT_MINIMUM_SIZE,
|
25
|
+
) -> None:
|
26
|
+
"""
|
27
|
+
Initialize the compression middleware.
|
28
|
+
|
29
|
+
Args:
|
30
|
+
app: The ASGI application.
|
31
|
+
algorithms: List of compression algorithms to use, in order of preference.
|
32
|
+
If not provided, no compression will be applied.
|
33
|
+
minimum_size: The minimum response size to apply compression.
|
34
|
+
This will be used as the default for algorithms that don't specify it.
|
35
|
+
"""
|
36
|
+
|
37
|
+
self.app = app
|
38
|
+
self.minimum_size = minimum_size
|
39
|
+
|
40
|
+
self.algorithms = algorithms or []
|
41
|
+
for algorithm in self.algorithms:
|
42
|
+
try:
|
43
|
+
algorithm.check_available()
|
44
|
+
except ImportError as e:
|
45
|
+
raise e from e
|
46
|
+
|
47
|
+
self._default_algorithm = IdentityAlgorithm(minimum_size=minimum_size)
|
48
|
+
|
49
|
+
# Set minimum_size if not explicitly set in the algorithm
|
50
|
+
for algorithm in self.algorithms:
|
51
|
+
if (
|
52
|
+
algorithm.minimum_size == DEFAULT_MINIMUM_SIZE
|
53
|
+
and minimum_size != DEFAULT_MINIMUM_SIZE
|
54
|
+
):
|
55
|
+
algorithm.minimum_size = minimum_size
|
56
|
+
|
57
|
+
async def __call__(
|
58
|
+
self,
|
59
|
+
scope: Scope,
|
60
|
+
receive: Receive,
|
61
|
+
send: Send,
|
62
|
+
) -> None:
|
63
|
+
"""ASGI application interface."""
|
64
|
+
if scope["type"] != "http": # pragma: no cover
|
65
|
+
await self.app(scope, receive, send)
|
66
|
+
return
|
67
|
+
|
68
|
+
headers = Headers(scope=scope)
|
69
|
+
accept_encoding = headers.get("Accept-Encoding", "")
|
70
|
+
|
71
|
+
# Find the first supported algorithm that matches the Accept-Encoding header
|
72
|
+
responder: Union[CompressionResponder, None] = None
|
73
|
+
for algorithm in self.algorithms:
|
74
|
+
if str(algorithm.type.value) in accept_encoding:
|
75
|
+
responder = algorithm.create_responder(self.app)
|
76
|
+
break
|
77
|
+
|
78
|
+
# If no matching algorithm, use identity (no compression)
|
79
|
+
if responder is None:
|
80
|
+
responder = self._default_algorithm.create_responder(self.app)
|
81
|
+
|
82
|
+
await responder(scope, receive, send)
|
@@ -0,0 +1,63 @@
|
|
1
|
+
from typing import (
|
2
|
+
Any,
|
3
|
+
Awaitable,
|
4
|
+
Callable,
|
5
|
+
List,
|
6
|
+
Mapping,
|
7
|
+
MutableMapping,
|
8
|
+
Optional,
|
9
|
+
)
|
10
|
+
|
11
|
+
from multidict import CIMultiDict
|
12
|
+
|
13
|
+
Scope = MutableMapping[str, Any]
|
14
|
+
Message = MutableMapping[str, Any]
|
15
|
+
Receive = Callable[[], Awaitable[Message]]
|
16
|
+
Send = Callable[[Message], Awaitable[None]]
|
17
|
+
ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
|
18
|
+
|
19
|
+
|
20
|
+
class Headers(CIMultiDict[str]):
|
21
|
+
def __init__(
|
22
|
+
self,
|
23
|
+
headers: Optional[Mapping[str, str]] = None,
|
24
|
+
raw: Optional[list[tuple[bytes, bytes]]] = None,
|
25
|
+
scope: Optional[Scope] = None,
|
26
|
+
) -> None:
|
27
|
+
headers_list: List[tuple[str, str]] = []
|
28
|
+
if headers is not None:
|
29
|
+
assert raw is None, 'Cannot set both "headers" and "raw".'
|
30
|
+
assert scope is None, 'Cannot set both "headers" and "scope".'
|
31
|
+
headers_list = list(headers.items())
|
32
|
+
elif raw is not None:
|
33
|
+
assert scope is None, 'Cannot set both "raw" and "scope".'
|
34
|
+
headers_list = [
|
35
|
+
(key.decode("latin-1"), value.decode("latin-1"))
|
36
|
+
for key, value in raw
|
37
|
+
]
|
38
|
+
elif scope is not None:
|
39
|
+
# scope["headers"] isn't necessarily a list
|
40
|
+
# it might be a tuple or other iterable
|
41
|
+
scope_headers = scope["headers"] = list(scope["headers"])
|
42
|
+
headers_list = [
|
43
|
+
(key.decode("latin-1"), value.decode("latin-1"))
|
44
|
+
for key, value in scope_headers
|
45
|
+
]
|
46
|
+
|
47
|
+
super().__init__(headers_list)
|
48
|
+
|
49
|
+
def add_vary_header(self, vary: str) -> None:
|
50
|
+
existing = self.get("vary")
|
51
|
+
if existing is not None:
|
52
|
+
# Check if the value is already in the Vary header to avoid duplication
|
53
|
+
values = [x.strip() for x in existing.split(",")]
|
54
|
+
if vary not in values:
|
55
|
+
vary = f"{existing}, {vary}"
|
56
|
+
|
57
|
+
self["vary"] = vary
|
58
|
+
|
59
|
+
def encode(self) -> list[tuple[bytes, bytes]]:
|
60
|
+
return [
|
61
|
+
(key.encode("latin-1"), value.encode("latin-1"))
|
62
|
+
for key, value in self.items()
|
63
|
+
]
|
asgi_compression/zstd.py
ADDED
@@ -0,0 +1,92 @@
|
|
1
|
+
import io
|
2
|
+
from dataclasses import dataclass
|
3
|
+
from typing import TYPE_CHECKING
|
4
|
+
|
5
|
+
from .base import CompressionAlgorithm, CompressionResponder, ContentEncoding
|
6
|
+
from .types import ASGIApp, Receive, Scope, Send
|
7
|
+
|
8
|
+
if TYPE_CHECKING:
|
9
|
+
import zstandard
|
10
|
+
|
11
|
+
|
12
|
+
def import_zstandard() -> None:
|
13
|
+
global zstandard
|
14
|
+
try:
|
15
|
+
import zstandard
|
16
|
+
except ImportError as e:
|
17
|
+
raise ImportError(
|
18
|
+
"zstandard is not installed, run `pip install zstandard`]"
|
19
|
+
) from e
|
20
|
+
|
21
|
+
|
22
|
+
class ZstdResponder(CompressionResponder):
|
23
|
+
"""Responder that applies Zstandard compression."""
|
24
|
+
|
25
|
+
content_encoding = ContentEncoding.ZSTD
|
26
|
+
|
27
|
+
def __init__(
|
28
|
+
self,
|
29
|
+
app: ASGIApp,
|
30
|
+
minimum_size: int,
|
31
|
+
level: int = 3,
|
32
|
+
threads: int = 0,
|
33
|
+
write_checksum: bool = False,
|
34
|
+
write_content_size: bool = True,
|
35
|
+
) -> None:
|
36
|
+
super().__init__(app, minimum_size)
|
37
|
+
|
38
|
+
import_zstandard()
|
39
|
+
|
40
|
+
self.zstd_buffer = io.BytesIO()
|
41
|
+
self.compressor = zstandard.ZstdCompressor(
|
42
|
+
level=level,
|
43
|
+
threads=threads,
|
44
|
+
write_checksum=write_checksum,
|
45
|
+
write_content_size=write_content_size,
|
46
|
+
)
|
47
|
+
self.compression_stream = self.compressor.stream_writer(
|
48
|
+
self.zstd_buffer
|
49
|
+
)
|
50
|
+
|
51
|
+
async def __call__(
|
52
|
+
self,
|
53
|
+
scope: Scope,
|
54
|
+
receive: Receive,
|
55
|
+
send: Send,
|
56
|
+
) -> None:
|
57
|
+
with self.zstd_buffer, self.compression_stream:
|
58
|
+
await super().__call__(scope, receive, send)
|
59
|
+
|
60
|
+
def apply_compression(self, body: bytes, *, more_body: bool) -> bytes:
|
61
|
+
self.compression_stream.write(body)
|
62
|
+
if not more_body:
|
63
|
+
self.compression_stream.flush(zstandard.FLUSH_FRAME)
|
64
|
+
|
65
|
+
body = self.zstd_buffer.getvalue()
|
66
|
+
self.zstd_buffer.seek(0)
|
67
|
+
self.zstd_buffer.truncate()
|
68
|
+
return body
|
69
|
+
|
70
|
+
|
71
|
+
@dataclass
|
72
|
+
class ZstdAlgorithm(CompressionAlgorithm):
|
73
|
+
"""Zstandard compression algorithm."""
|
74
|
+
|
75
|
+
type: ContentEncoding = ContentEncoding.ZSTD
|
76
|
+
level: int = 3
|
77
|
+
threads: int = 0
|
78
|
+
write_checksum: bool = False
|
79
|
+
write_content_size: bool = True
|
80
|
+
|
81
|
+
def create_responder(self, app: ASGIApp) -> ZstdResponder:
|
82
|
+
return ZstdResponder(
|
83
|
+
app=app,
|
84
|
+
minimum_size=self.minimum_size,
|
85
|
+
level=self.level,
|
86
|
+
threads=self.threads,
|
87
|
+
write_checksum=self.write_checksum,
|
88
|
+
write_content_size=self.write_content_size,
|
89
|
+
)
|
90
|
+
|
91
|
+
def check_available(self) -> None:
|
92
|
+
import_zstandard()
|
@@ -0,0 +1,14 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: asgi-compression
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary: Add your description here
|
5
|
+
Requires-Python: >=3.9
|
6
|
+
Description-Content-Type: text/markdown
|
7
|
+
Requires-Dist: multidict>=6.2.0
|
8
|
+
Provides-Extra: all
|
9
|
+
Requires-Dist: brotli>=1.1.0; extra == "all"
|
10
|
+
Requires-Dist: zstandard>=0.23.0; extra == "all"
|
11
|
+
Provides-Extra: br
|
12
|
+
Requires-Dist: brotli>=1.1.0; extra == "br"
|
13
|
+
Provides-Extra: zstd
|
14
|
+
Requires-Dist: zstandard>=0.23.0; extra == "zstd"
|
@@ -0,0 +1,12 @@
|
|
1
|
+
asgi_compression/__init__.py,sha256=hp71J4CeuSECF52-hVS-mia_m0zcXlHV72qABNww_yY,457
|
2
|
+
asgi_compression/base.py,sha256=SmWJFT1Iy5I6Sa4_Vp2uMIr4iaQE6iq686havVoZPmQ,5145
|
3
|
+
asgi_compression/brotli.py,sha256=e2YHx1anKJkvHgw0OoDJK_adzZ1ZKLwdrPgDjkUmoQU,2633
|
4
|
+
asgi_compression/gzip.py,sha256=dj7kaH5_VbElH2U88oKVcNQPqKTgXl2lE6ghH5Qo0CU,1608
|
5
|
+
asgi_compression/identity.py,sha256=tIGUAXxA6YJS0KsZO8se4dlR1_q6_YJNXIBU06-AE98,692
|
6
|
+
asgi_compression/middleware.py,sha256=bbH_bdsB3MSgdSN3qMj2VjkGqeGiBGBQRclEsQ6dFHo,2737
|
7
|
+
asgi_compression/types.py,sha256=QozTp5Ebehh-I-tv_DkZnjQJ0Z91_Mk5W8-rBI4E0rE,2087
|
8
|
+
asgi_compression/zstd.py,sha256=HYX80EzHKy38w5ygAeEcR4Y8a4KVi1UNyns63ziAuvc,2518
|
9
|
+
asgi_compression-0.1.0.dist-info/METADATA,sha256=DhbHj5rGQfMI4olwnY3vtrIuJSYq5SyXohPztAf51MI,438
|
10
|
+
asgi_compression-0.1.0.dist-info/WHEEL,sha256=lTU6B6eIfYoiQJTZNc-fyaR6BpL6ehTzU3xGYxn2n8k,91
|
11
|
+
asgi_compression-0.1.0.dist-info/top_level.txt,sha256=ReOgAA8xTuoMxA4oROBnjJb7yoIjMe7xAB9dz_eXU0Q,17
|
12
|
+
asgi_compression-0.1.0.dist-info/RECORD,,
|
@@ -0,0 +1 @@
|
|
1
|
+
asgi_compression
|