tigrcorn-asgi 0.3.16.dev13__tar.gz → 0.3.17.dev1__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.
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/PKG-INFO +2 -2
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/pyproject.toml +2 -2
- tigrcorn_asgi-0.3.17.dev1/src/tigrcorn_asgi/send/__init__.py +27 -0
- tigrcorn_asgi-0.3.17.dev1/src/tigrcorn_asgi/send/collector.py +192 -0
- tigrcorn_asgi-0.3.17.dev1/src/tigrcorn_asgi/send/helpers.py +30 -0
- tigrcorn_asgi-0.3.17.dev1/src/tigrcorn_asgi/send/lifespan.py +14 -0
- tigrcorn_asgi-0.3.17.dev1/src/tigrcorn_asgi/send/materialize.py +73 -0
- tigrcorn_asgi-0.3.17.dev1/src/tigrcorn_asgi/send/segments.py +18 -0
- tigrcorn_asgi-0.3.17.dev1/src/tigrcorn_asgi/send/validation.py +50 -0
- tigrcorn_asgi-0.3.17.dev1/src/tigrcorn_asgi/send/writer.py +343 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi.egg-info/PKG-INFO +2 -2
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi.egg-info/SOURCES.txt +9 -2
- tigrcorn_asgi-0.3.17.dev1/src/tigrcorn_asgi.egg-info/requires.txt +1 -0
- tigrcorn_asgi-0.3.16.dev13/src/tigrcorn_asgi/send.py +0 -670
- tigrcorn_asgi-0.3.16.dev13/src/tigrcorn_asgi.egg-info/requires.txt +0 -1
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/LICENSE +0 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/README.md +0 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/setup.cfg +0 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/__init__.py +0 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/connection.py +0 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/errors.py +0 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/events/__init__.py +0 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/events/custom.py +0 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/events/http.py +0 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/events/lifespan.py +0 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/events/websocket.py +0 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/extensions/__init__.py +0 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/extensions/tls.py +0 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/extensions/websocket_denial.py +0 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/py.typed +0 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/receive.py +0 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/scopes/__init__.py +0 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/scopes/custom.py +0 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/scopes/http.py +0 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/scopes/lifespan.py +0 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/scopes/websocket.py +0 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/state.py +0 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi.egg-info/dependency_links.txt +0 -0
- {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tigrcorn-asgi
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.17.dev1
|
|
4
4
|
Summary: ASGI3 scope, event, receive/send, and extension primitives for the Tigrcorn Python web server.
|
|
5
5
|
Author-email: Jacob Stewart <jacob@swarmauri.com>
|
|
6
6
|
License: Apache License
|
|
@@ -186,7 +186,7 @@ Classifier: Typing :: Typed
|
|
|
186
186
|
Requires-Python: <3.15,>=3.10
|
|
187
187
|
Description-Content-Type: text/markdown
|
|
188
188
|
License-File: LICENSE
|
|
189
|
-
Requires-Dist: tigrcorn-core==0.3.
|
|
189
|
+
Requires-Dist: tigrcorn-core==0.3.17.dev1
|
|
190
190
|
Dynamic: license-file
|
|
191
191
|
|
|
192
192
|
<div align="center">
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tigrcorn-asgi"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.17.dev1"
|
|
8
8
|
description = "ASGI3 scope, event, receive/send, and extension primitives for the Tigrcorn Python web server."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10,<3.15"
|
|
@@ -28,7 +28,7 @@ classifiers = [
|
|
|
28
28
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
29
29
|
"Typing :: Typed",
|
|
30
30
|
]
|
|
31
|
-
dependencies = ["tigrcorn-core==0.3.
|
|
31
|
+
dependencies = ["tigrcorn-core==0.3.17.dev1"]
|
|
32
32
|
|
|
33
33
|
[tool.setuptools]
|
|
34
34
|
package-dir = {"" = "src"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .collector import HTTPResponseCollector
|
|
4
|
+
from .helpers import response_body_segments_have_bytes
|
|
5
|
+
from .lifespan import LifespanSend
|
|
6
|
+
from .materialize import iter_response_body_segments, materialize_response_body_segments
|
|
7
|
+
from .segments import BodySegment, FileBodySegment, MemoryBodySegment
|
|
8
|
+
from .validation import normalize_response_file_segments, normalize_response_pathsend_segment
|
|
9
|
+
from .writer import HTTPResponseWriter
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"BodySegment",
|
|
13
|
+
"DEFAULT_RESPONSE_BODY_SPOOL_THRESHOLD",
|
|
14
|
+
"FileBodySegment",
|
|
15
|
+
"HTTPResponseCollector",
|
|
16
|
+
"HTTPResponseWriter",
|
|
17
|
+
"LifespanSend",
|
|
18
|
+
"MemoryBodySegment",
|
|
19
|
+
"iter_response_body_segments",
|
|
20
|
+
"materialize_response_body_segments",
|
|
21
|
+
"normalize_response_file_segments",
|
|
22
|
+
"normalize_response_pathsend_segment",
|
|
23
|
+
"response_body_segments_have_bytes",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DEFAULT_RESPONSE_BODY_SPOOL_THRESHOLD = 256 * 1024
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from tigrcorn_asgi.errors import ASGIProtocolError
|
|
9
|
+
|
|
10
|
+
from .helpers import format_strong_etag
|
|
11
|
+
from .materialize import materialize_response_body_segments
|
|
12
|
+
from .segments import BodySegment, FileBodySegment
|
|
13
|
+
from .validation import normalize_response_file_segments, normalize_response_pathsend_segment
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _default_spool_threshold() -> int:
|
|
17
|
+
from tigrcorn_asgi import send
|
|
18
|
+
|
|
19
|
+
return int(send.DEFAULT_RESPONSE_BODY_SPOOL_THRESHOLD)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class HTTPResponseCollector:
|
|
24
|
+
status: int | None = None
|
|
25
|
+
headers: list[tuple[bytes, bytes]] = field(default_factory=list)
|
|
26
|
+
body_parts: list[bytes] = field(default_factory=list)
|
|
27
|
+
trailers: list[tuple[bytes, bytes]] = field(default_factory=list)
|
|
28
|
+
complete: bool = False
|
|
29
|
+
informational_responses: list[tuple[int, list[tuple[bytes, bytes]]]] = field(default_factory=list)
|
|
30
|
+
body_segments: list[BodySegment] = field(default_factory=list)
|
|
31
|
+
uses_streamed_body: bool = False
|
|
32
|
+
spool_threshold: int = field(default_factory=_default_spool_threshold)
|
|
33
|
+
body_length: int = 0
|
|
34
|
+
_body_digest: object = field(default_factory=lambda: hashlib.blake2s(digest_size=16), repr=False)
|
|
35
|
+
_spool_path: str | None = field(default=None, init=False, repr=False)
|
|
36
|
+
_spool_handle: object | None = field(default=None, init=False, repr=False)
|
|
37
|
+
_body_channel: str | None = field(default=None, init=False, repr=False)
|
|
38
|
+
|
|
39
|
+
def _record_body_chunk(self, chunk: bytes) -> None:
|
|
40
|
+
if not chunk:
|
|
41
|
+
return
|
|
42
|
+
self.body_length += len(chunk)
|
|
43
|
+
self._body_digest.update(chunk)
|
|
44
|
+
|
|
45
|
+
def has_spooled_body(self) -> bool:
|
|
46
|
+
return self._spool_path is not None
|
|
47
|
+
|
|
48
|
+
def generated_entity_tag(self) -> bytes:
|
|
49
|
+
return format_strong_etag(self._body_digest.hexdigest().encode("ascii"))
|
|
50
|
+
|
|
51
|
+
def _ensure_spool_file(self) -> None:
|
|
52
|
+
if self._spool_handle is not None and self._spool_path is not None:
|
|
53
|
+
return
|
|
54
|
+
handle = tempfile.NamedTemporaryFile(prefix="tigrcorn-response-", suffix=".bin", delete=False)
|
|
55
|
+
self._spool_handle = handle
|
|
56
|
+
self._spool_path = handle.name
|
|
57
|
+
if self.body_parts:
|
|
58
|
+
for part in self.body_parts:
|
|
59
|
+
if part:
|
|
60
|
+
handle.write(part)
|
|
61
|
+
handle.flush()
|
|
62
|
+
self.body_parts.clear()
|
|
63
|
+
|
|
64
|
+
def _flush_spool(self) -> None:
|
|
65
|
+
handle = self._spool_handle
|
|
66
|
+
if handle is not None:
|
|
67
|
+
handle.flush()
|
|
68
|
+
|
|
69
|
+
def spooled_body_segments(self) -> list[BodySegment]:
|
|
70
|
+
if self._spool_path is None:
|
|
71
|
+
return []
|
|
72
|
+
self._flush_spool()
|
|
73
|
+
return [FileBodySegment(self._spool_path, 0, self.body_length)]
|
|
74
|
+
|
|
75
|
+
async def materialize_body(self) -> bytes:
|
|
76
|
+
self.finalize()
|
|
77
|
+
if self._spool_path is None:
|
|
78
|
+
return b"".join(self.body_parts)
|
|
79
|
+
return await materialize_response_body_segments(self.spooled_body_segments())
|
|
80
|
+
|
|
81
|
+
def cleanup(self) -> None:
|
|
82
|
+
handle = self._spool_handle
|
|
83
|
+
self._spool_handle = None
|
|
84
|
+
if handle is not None:
|
|
85
|
+
try:
|
|
86
|
+
handle.close()
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
path = self._spool_path
|
|
90
|
+
self._spool_path = None
|
|
91
|
+
if path:
|
|
92
|
+
try:
|
|
93
|
+
os.unlink(path)
|
|
94
|
+
except FileNotFoundError:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
async def __call__(self, message: dict) -> None:
|
|
98
|
+
message_type = message["type"]
|
|
99
|
+
if message_type == "http.response.start":
|
|
100
|
+
self._handle_response_start(message)
|
|
101
|
+
return
|
|
102
|
+
if message_type == "http.response.body":
|
|
103
|
+
self._handle_response_body(message)
|
|
104
|
+
return
|
|
105
|
+
if message_type == "tigrcorn.http.response.file":
|
|
106
|
+
self._handle_response_file(message)
|
|
107
|
+
return
|
|
108
|
+
if message_type == "http.response.pathsend":
|
|
109
|
+
self._handle_response_pathsend(message)
|
|
110
|
+
return
|
|
111
|
+
if message_type == "http.response.trailers":
|
|
112
|
+
self._handle_response_trailers(message)
|
|
113
|
+
return
|
|
114
|
+
raise ASGIProtocolError(f"unexpected HTTP send event: {message_type!r}")
|
|
115
|
+
|
|
116
|
+
def _handle_response_start(self, message: dict) -> None:
|
|
117
|
+
status = int(message["status"])
|
|
118
|
+
headers = list(message.get("headers", []))
|
|
119
|
+
if status < 200:
|
|
120
|
+
if self.status is not None or self.body_parts or self.complete or self.uses_streamed_body or self.has_spooled_body():
|
|
121
|
+
raise ASGIProtocolError("informational response sent after final response start")
|
|
122
|
+
self.informational_responses.append((status, headers))
|
|
123
|
+
return
|
|
124
|
+
if self.status is not None:
|
|
125
|
+
raise ASGIProtocolError("http.response.start sent more than once")
|
|
126
|
+
self.status = status
|
|
127
|
+
self.headers = headers
|
|
128
|
+
|
|
129
|
+
def _handle_response_body(self, message: dict) -> None:
|
|
130
|
+
if self.status is None:
|
|
131
|
+
raise ASGIProtocolError("http.response.body sent before final http.response.start")
|
|
132
|
+
if self._body_channel in {"file", "pathsend"}:
|
|
133
|
+
raise ASGIProtocolError("http.response.body cannot follow streamed file response")
|
|
134
|
+
self._body_channel = "body"
|
|
135
|
+
chunk = bytes(message.get("body", b""))
|
|
136
|
+
self._record_body_chunk(chunk)
|
|
137
|
+
should_spool = self.has_spooled_body() or (self.spool_threshold > 0 and self.body_length > self.spool_threshold)
|
|
138
|
+
if should_spool:
|
|
139
|
+
self._ensure_spool_file()
|
|
140
|
+
if chunk:
|
|
141
|
+
assert self._spool_handle is not None
|
|
142
|
+
self._spool_handle.write(chunk)
|
|
143
|
+
else:
|
|
144
|
+
self.body_parts.append(chunk)
|
|
145
|
+
self.complete = not bool(message.get("more_body", False))
|
|
146
|
+
|
|
147
|
+
def _handle_response_file(self, message: dict) -> None:
|
|
148
|
+
if self.status is None:
|
|
149
|
+
raise ASGIProtocolError("tigrcorn.http.response.file sent before final http.response.start")
|
|
150
|
+
if self.body_parts or self.has_spooled_body() or self._body_channel == "body":
|
|
151
|
+
raise ASGIProtocolError("tigrcorn.http.response.file cannot follow buffered body events")
|
|
152
|
+
if self._body_channel == "pathsend":
|
|
153
|
+
raise ASGIProtocolError("tigrcorn.http.response.file cannot follow http.response.pathsend")
|
|
154
|
+
self._body_channel = "file"
|
|
155
|
+
self.uses_streamed_body = True
|
|
156
|
+
self.body_segments.extend(normalize_response_file_segments(message.get("segments")))
|
|
157
|
+
self.complete = not bool(message.get("more_body", False))
|
|
158
|
+
|
|
159
|
+
def _handle_response_pathsend(self, message: dict) -> None:
|
|
160
|
+
if self.status is None:
|
|
161
|
+
raise ASGIProtocolError("http.response.pathsend sent before final http.response.start")
|
|
162
|
+
if self.body_parts or self.has_spooled_body() or self._body_channel is not None:
|
|
163
|
+
raise ASGIProtocolError("http.response.pathsend cannot be mixed with buffered or streamed body events")
|
|
164
|
+
if bool(message.get("more_body", False)):
|
|
165
|
+
raise ASGIProtocolError("http.response.pathsend does not support more_body")
|
|
166
|
+
self._body_channel = "pathsend"
|
|
167
|
+
self.uses_streamed_body = True
|
|
168
|
+
self.body_segments.append(normalize_response_pathsend_segment(message.get("path")))
|
|
169
|
+
self.complete = True
|
|
170
|
+
|
|
171
|
+
def _handle_response_trailers(self, message: dict) -> None:
|
|
172
|
+
if self.status is None:
|
|
173
|
+
raise ASGIProtocolError("http.response.trailers sent before final http.response.start")
|
|
174
|
+
self.trailers.extend(list(message.get("trailers", [])))
|
|
175
|
+
self.complete = not bool(message.get("more_trailers", False))
|
|
176
|
+
|
|
177
|
+
def finalize(self) -> None:
|
|
178
|
+
if self.status is None:
|
|
179
|
+
raise ASGIProtocolError("application did not send final http.response.start")
|
|
180
|
+
if not self.complete:
|
|
181
|
+
raise ASGIProtocolError("application returned before completing the response body")
|
|
182
|
+
self._flush_spool()
|
|
183
|
+
|
|
184
|
+
def response_tuple(self) -> tuple[int, list[tuple[bytes, bytes]], bytes, list[tuple[bytes, bytes]]]:
|
|
185
|
+
self.finalize()
|
|
186
|
+
assert self.status is not None
|
|
187
|
+
if self._spool_path is None:
|
|
188
|
+
body = b"".join(self.body_parts)
|
|
189
|
+
else:
|
|
190
|
+
with open(self._spool_path, "rb") as handle:
|
|
191
|
+
body = handle.read()
|
|
192
|
+
return self.status, self.headers, body, list(self.trailers)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .segments import BodySegment, MemoryBodySegment
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def format_strong_etag(value: bytes | str) -> bytes:
|
|
9
|
+
if isinstance(value, bytes):
|
|
10
|
+
text = value.decode("latin1")
|
|
11
|
+
else:
|
|
12
|
+
text = value
|
|
13
|
+
opaque = text.replace("\\", "\\\\").replace('"', '\\"').encode("latin1")
|
|
14
|
+
return b'"' + opaque + b'"'
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def segment_length(segment: BodySegment) -> int:
|
|
18
|
+
if isinstance(segment, MemoryBodySegment):
|
|
19
|
+
return len(segment.data)
|
|
20
|
+
if segment.count is not None:
|
|
21
|
+
return max(int(segment.count), 0)
|
|
22
|
+
try:
|
|
23
|
+
size = Path(segment.path).stat().st_size
|
|
24
|
+
except FileNotFoundError:
|
|
25
|
+
return 0
|
|
26
|
+
return max(size - int(segment.offset), 0)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def response_body_segments_have_bytes(segments: list[BodySegment] | tuple[BodySegment, ...]) -> bool:
|
|
30
|
+
return any(segment_length(segment) > 0 for segment in segments)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LifespanSend:
|
|
7
|
+
def __init__(self) -> None:
|
|
8
|
+
self._queue: asyncio.Queue[dict] = asyncio.Queue()
|
|
9
|
+
|
|
10
|
+
async def __call__(self, message: dict) -> None:
|
|
11
|
+
await self._queue.put(message)
|
|
12
|
+
|
|
13
|
+
async def get(self) -> dict:
|
|
14
|
+
return await self._queue.get()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from .segments import BodySegment, FileBodySegment, MemoryBodySegment
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def iter_file_segment_bytes(segment: FileBodySegment, *, chunk_size: int = 64 * 1024):
|
|
10
|
+
path = os.fspath(segment.path)
|
|
11
|
+
remaining = segment.count
|
|
12
|
+
position = segment.offset
|
|
13
|
+
if remaining is not None and remaining <= 0:
|
|
14
|
+
return
|
|
15
|
+
if hasattr(os, "pread"):
|
|
16
|
+
fd = os.open(path, os.O_RDONLY)
|
|
17
|
+
try:
|
|
18
|
+
while remaining is None or remaining > 0:
|
|
19
|
+
size = chunk_size if remaining is None else min(chunk_size, remaining)
|
|
20
|
+
if size <= 0:
|
|
21
|
+
break
|
|
22
|
+
chunk = await asyncio.to_thread(os.pread, fd, size, position)
|
|
23
|
+
if not chunk:
|
|
24
|
+
break
|
|
25
|
+
position += len(chunk)
|
|
26
|
+
if remaining is not None:
|
|
27
|
+
remaining -= len(chunk)
|
|
28
|
+
yield chunk
|
|
29
|
+
finally:
|
|
30
|
+
os.close(fd)
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
def _read_chunk(current: int, size: int) -> bytes:
|
|
34
|
+
with open(path, "rb") as handle:
|
|
35
|
+
handle.seek(current)
|
|
36
|
+
return handle.read(size)
|
|
37
|
+
|
|
38
|
+
while remaining is None or remaining > 0:
|
|
39
|
+
size = chunk_size if remaining is None else min(chunk_size, remaining)
|
|
40
|
+
if size <= 0:
|
|
41
|
+
break
|
|
42
|
+
chunk = await asyncio.to_thread(_read_chunk, position, size)
|
|
43
|
+
if not chunk:
|
|
44
|
+
break
|
|
45
|
+
position += len(chunk)
|
|
46
|
+
if remaining is not None:
|
|
47
|
+
remaining -= len(chunk)
|
|
48
|
+
yield chunk
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def iter_response_body_segments(
|
|
52
|
+
segments: list[BodySegment] | tuple[BodySegment, ...],
|
|
53
|
+
*,
|
|
54
|
+
chunk_size: int = 64 * 1024,
|
|
55
|
+
):
|
|
56
|
+
for segment in segments:
|
|
57
|
+
if isinstance(segment, MemoryBodySegment):
|
|
58
|
+
if segment.data:
|
|
59
|
+
yield bytes(segment.data)
|
|
60
|
+
continue
|
|
61
|
+
async for chunk in iter_file_segment_bytes(segment, chunk_size=chunk_size):
|
|
62
|
+
yield chunk
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def materialize_response_body_segments(
|
|
66
|
+
segments: list[BodySegment] | tuple[BodySegment, ...],
|
|
67
|
+
*,
|
|
68
|
+
chunk_size: int = 64 * 1024,
|
|
69
|
+
) -> bytes:
|
|
70
|
+
chunks: list[bytes] = []
|
|
71
|
+
async for chunk in iter_response_body_segments(segments, chunk_size=chunk_size):
|
|
72
|
+
chunks.append(chunk)
|
|
73
|
+
return b"".join(chunks)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True, slots=True)
|
|
7
|
+
class MemoryBodySegment:
|
|
8
|
+
data: bytes
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class FileBodySegment:
|
|
13
|
+
path: str
|
|
14
|
+
offset: int = 0
|
|
15
|
+
count: int | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
BodySegment = MemoryBodySegment | FileBodySegment
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from tigrcorn_asgi.errors import ASGIProtocolError
|
|
7
|
+
|
|
8
|
+
from .segments import BodySegment, FileBodySegment, MemoryBodySegment
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def normalize_response_file_segments(raw_segments: object | None) -> list[BodySegment]:
|
|
12
|
+
segments: list[BodySegment] = []
|
|
13
|
+
for raw in raw_segments or ():
|
|
14
|
+
if isinstance(raw, (MemoryBodySegment, FileBodySegment)):
|
|
15
|
+
segments.append(raw)
|
|
16
|
+
continue
|
|
17
|
+
if isinstance(raw, (bytes, bytearray, memoryview)):
|
|
18
|
+
segments.append(MemoryBodySegment(bytes(raw)))
|
|
19
|
+
continue
|
|
20
|
+
if not isinstance(raw, dict):
|
|
21
|
+
raise ASGIProtocolError(f"invalid tigrcorn.http.response.file segment: {raw!r}")
|
|
22
|
+
segment_type = str(raw.get("type", "file")).lower()
|
|
23
|
+
if segment_type == "memory":
|
|
24
|
+
segments.append(MemoryBodySegment(bytes(raw.get("body", b""))))
|
|
25
|
+
continue
|
|
26
|
+
if segment_type != "file":
|
|
27
|
+
raise ASGIProtocolError(f"unsupported tigrcorn.http.response.file segment type: {segment_type!r}")
|
|
28
|
+
count_raw = raw.get("count")
|
|
29
|
+
segments.append(
|
|
30
|
+
FileBodySegment(
|
|
31
|
+
path=os.fspath(raw["path"]),
|
|
32
|
+
offset=int(raw.get("offset", 0)),
|
|
33
|
+
count=None if count_raw is None else int(count_raw),
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
return segments
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def normalize_response_pathsend_segment(raw_path: object) -> FileBodySegment:
|
|
40
|
+
path = os.fspath(raw_path)
|
|
41
|
+
if not os.path.isabs(path):
|
|
42
|
+
raise ASGIProtocolError("http.response.pathsend requires an absolute file path")
|
|
43
|
+
candidate = Path(path)
|
|
44
|
+
try:
|
|
45
|
+
size = candidate.stat().st_size
|
|
46
|
+
except FileNotFoundError as exc:
|
|
47
|
+
raise ASGIProtocolError("http.response.pathsend requires an existing file path") from exc
|
|
48
|
+
if not candidate.is_file():
|
|
49
|
+
raise ASGIProtocolError("http.response.pathsend requires a regular file path")
|
|
50
|
+
return FileBodySegment(path, 0, size)
|