Skip to content

Commit 9700790

Browse files
committed
Organize ASGI send module
1 parent d190f4a commit 9700790

11 files changed

Lines changed: 826 additions & 670 deletions

File tree

pkgs/tigrcorn-asgi/src/tigrcorn_asgi/send.py

Lines changed: 0 additions & 670 deletions
This file was deleted.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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()
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 commit comments

Comments
 (0)