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.
Files changed (39) hide show
  1. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/PKG-INFO +2 -2
  2. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/pyproject.toml +2 -2
  3. tigrcorn_asgi-0.3.17.dev1/src/tigrcorn_asgi/send/__init__.py +27 -0
  4. tigrcorn_asgi-0.3.17.dev1/src/tigrcorn_asgi/send/collector.py +192 -0
  5. tigrcorn_asgi-0.3.17.dev1/src/tigrcorn_asgi/send/helpers.py +30 -0
  6. tigrcorn_asgi-0.3.17.dev1/src/tigrcorn_asgi/send/lifespan.py +14 -0
  7. tigrcorn_asgi-0.3.17.dev1/src/tigrcorn_asgi/send/materialize.py +73 -0
  8. tigrcorn_asgi-0.3.17.dev1/src/tigrcorn_asgi/send/segments.py +18 -0
  9. tigrcorn_asgi-0.3.17.dev1/src/tigrcorn_asgi/send/validation.py +50 -0
  10. tigrcorn_asgi-0.3.17.dev1/src/tigrcorn_asgi/send/writer.py +343 -0
  11. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi.egg-info/PKG-INFO +2 -2
  12. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi.egg-info/SOURCES.txt +9 -2
  13. tigrcorn_asgi-0.3.17.dev1/src/tigrcorn_asgi.egg-info/requires.txt +1 -0
  14. tigrcorn_asgi-0.3.16.dev13/src/tigrcorn_asgi/send.py +0 -670
  15. tigrcorn_asgi-0.3.16.dev13/src/tigrcorn_asgi.egg-info/requires.txt +0 -1
  16. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/LICENSE +0 -0
  17. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/README.md +0 -0
  18. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/setup.cfg +0 -0
  19. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/__init__.py +0 -0
  20. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/connection.py +0 -0
  21. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/errors.py +0 -0
  22. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/events/__init__.py +0 -0
  23. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/events/custom.py +0 -0
  24. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/events/http.py +0 -0
  25. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/events/lifespan.py +0 -0
  26. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/events/websocket.py +0 -0
  27. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/extensions/__init__.py +0 -0
  28. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/extensions/tls.py +0 -0
  29. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/extensions/websocket_denial.py +0 -0
  30. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/py.typed +0 -0
  31. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/receive.py +0 -0
  32. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/scopes/__init__.py +0 -0
  33. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/scopes/custom.py +0 -0
  34. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/scopes/http.py +0 -0
  35. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/scopes/lifespan.py +0 -0
  36. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/scopes/websocket.py +0 -0
  37. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi/state.py +0 -0
  38. {tigrcorn_asgi-0.3.16.dev13 → tigrcorn_asgi-0.3.17.dev1}/src/tigrcorn_asgi.egg-info/dependency_links.txt +0 -0
  39. {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.16.dev13
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.16.dev13
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.16.dev13"
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.16.dev13"]
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)