Coverage for /pythoncovmergedfiles/medio/medio/src/aiohttp/aiohttp/web_fileresponse.py: 16%
140 statements
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-27 06:09 +0000
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-27 06:09 +0000
1import asyncio
2import mimetypes
3import os
4import pathlib
5from typing import (
6 IO,
7 TYPE_CHECKING,
8 Any,
9 Awaitable,
10 Callable,
11 Final,
12 Optional,
13 Tuple,
14 cast,
15)
17from . import hdrs
18from .abc import AbstractStreamWriter
19from .helpers import ETAG_ANY, ETag
20from .typedefs import LooseHeaders, PathLike
21from .web_exceptions import (
22 HTTPNotModified,
23 HTTPPartialContent,
24 HTTPPreconditionFailed,
25 HTTPRequestRangeNotSatisfiable,
26)
27from .web_response import StreamResponse
29__all__ = ("FileResponse",)
31if TYPE_CHECKING: # pragma: no cover
32 from .web_request import BaseRequest
35_T_OnChunkSent = Optional[Callable[[bytes], Awaitable[None]]]
38NOSENDFILE: Final[bool] = bool(os.environ.get("AIOHTTP_NOSENDFILE"))
41class FileResponse(StreamResponse):
42 """A response object can be used to send files."""
44 def __init__(
45 self,
46 path: PathLike,
47 chunk_size: int = 256 * 1024,
48 status: int = 200,
49 reason: Optional[str] = None,
50 headers: Optional[LooseHeaders] = None,
51 ) -> None:
52 super().__init__(status=status, reason=reason, headers=headers)
54 self._path = pathlib.Path(path)
55 self._chunk_size = chunk_size
57 async def _sendfile_fallback(
58 self, writer: AbstractStreamWriter, fobj: IO[Any], offset: int, count: int
59 ) -> AbstractStreamWriter:
60 # To keep memory usage low,fobj is transferred in chunks
61 # controlled by the constructor's chunk_size argument.
63 chunk_size = self._chunk_size
64 loop = asyncio.get_event_loop()
66 await loop.run_in_executor(None, fobj.seek, offset)
68 chunk = await loop.run_in_executor(None, fobj.read, chunk_size)
69 while chunk:
70 await writer.write(chunk)
71 count = count - chunk_size
72 if count <= 0:
73 break
74 chunk = await loop.run_in_executor(None, fobj.read, min(chunk_size, count))
76 await writer.drain()
77 return writer
79 async def _sendfile(
80 self, request: "BaseRequest", fobj: IO[Any], offset: int, count: int
81 ) -> AbstractStreamWriter:
82 writer = await super().prepare(request)
83 assert writer is not None
85 if NOSENDFILE or self.compression:
86 return await self._sendfile_fallback(writer, fobj, offset, count)
88 loop = request._loop
89 transport = request.transport
90 assert transport is not None
92 try:
93 await loop.sendfile(transport, fobj, offset, count)
94 except NotImplementedError:
95 return await self._sendfile_fallback(writer, fobj, offset, count)
97 await super().write_eof()
98 return writer
100 @staticmethod
101 def _strong_etag_match(etag_value: str, etags: Tuple[ETag, ...]) -> bool:
102 if len(etags) == 1 and etags[0].value == ETAG_ANY:
103 return True
104 return any(etag.value == etag_value for etag in etags if not etag.is_weak)
106 async def _not_modified(
107 self, request: "BaseRequest", etag_value: str, last_modified: float
108 ) -> Optional[AbstractStreamWriter]:
109 self.set_status(HTTPNotModified.status_code)
110 self._length_check = False
111 self.etag = etag_value # type: ignore[assignment]
112 self.last_modified = last_modified # type: ignore[assignment]
113 # Delete any Content-Length headers provided by user. HTTP 304
114 # should always have empty response body
115 return await super().prepare(request)
117 async def _precondition_failed(
118 self, request: "BaseRequest"
119 ) -> Optional[AbstractStreamWriter]:
120 self.set_status(HTTPPreconditionFailed.status_code)
121 self.content_length = 0
122 return await super().prepare(request)
124 async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter]:
125 filepath = self._path
127 gzip = False
128 if "gzip" in request.headers.get(hdrs.ACCEPT_ENCODING, ""):
129 gzip_path = filepath.with_name(filepath.name + ".gz")
131 if gzip_path.is_file():
132 filepath = gzip_path
133 gzip = True
135 loop = asyncio.get_event_loop()
136 st: os.stat_result = await loop.run_in_executor(None, filepath.stat)
138 etag_value = f"{st.st_mtime_ns:x}-{st.st_size:x}"
139 last_modified = st.st_mtime
141 # https://tools.ietf.org/html/rfc7232#section-6
142 ifmatch = request.if_match
143 if ifmatch is not None and not self._strong_etag_match(etag_value, ifmatch):
144 return await self._precondition_failed(request)
146 unmodsince = request.if_unmodified_since
147 if (
148 unmodsince is not None
149 and ifmatch is None
150 and st.st_mtime > unmodsince.timestamp()
151 ):
152 return await self._precondition_failed(request)
154 ifnonematch = request.if_none_match
155 if ifnonematch is not None and self._strong_etag_match(etag_value, ifnonematch):
156 return await self._not_modified(request, etag_value, last_modified)
158 modsince = request.if_modified_since
159 if (
160 modsince is not None
161 and ifnonematch is None
162 and st.st_mtime <= modsince.timestamp()
163 ):
164 return await self._not_modified(request, etag_value, last_modified)
166 ct = None
167 if hdrs.CONTENT_TYPE not in self.headers:
168 ct, encoding = mimetypes.guess_type(str(filepath))
169 if not ct:
170 ct = "application/octet-stream"
171 else:
172 encoding = "gzip" if gzip else None
174 status = self._status
175 file_size = st.st_size
176 count = file_size
178 start = None
180 ifrange = request.if_range
181 if ifrange is None or st.st_mtime <= ifrange.timestamp():
182 # If-Range header check:
183 # condition = cached date >= last modification date
184 # return 206 if True else 200.
185 # if False:
186 # Range header would not be processed, return 200
187 # if True but Range header missing
188 # return 200
189 try:
190 rng = request.http_range
191 start = rng.start
192 end = rng.stop
193 except ValueError:
194 # https://tools.ietf.org/html/rfc7233:
195 # A server generating a 416 (Range Not Satisfiable) response to
196 # a byte-range request SHOULD send a Content-Range header field
197 # with an unsatisfied-range value.
198 # The complete-length in a 416 response indicates the current
199 # length of the selected representation.
200 #
201 # Will do the same below. Many servers ignore this and do not
202 # send a Content-Range header with HTTP 416
203 self.headers[hdrs.CONTENT_RANGE] = f"bytes */{file_size}"
204 self.set_status(HTTPRequestRangeNotSatisfiable.status_code)
205 return await super().prepare(request)
207 # If a range request has been made, convert start, end slice
208 # notation into file pointer offset and count
209 if start is not None or end is not None:
210 if start < 0 and end is None: # return tail of file
211 start += file_size
212 if start < 0:
213 # if Range:bytes=-1000 in request header but file size
214 # is only 200, there would be trouble without this
215 start = 0
216 count = file_size - start
217 else:
218 # rfc7233:If the last-byte-pos value is
219 # absent, or if the value is greater than or equal to
220 # the current length of the representation data,
221 # the byte range is interpreted as the remainder
222 # of the representation (i.e., the server replaces the
223 # value of last-byte-pos with a value that is one less than
224 # the current length of the selected representation).
225 count = (
226 min(end if end is not None else file_size, file_size) - start
227 )
229 if start >= file_size:
230 # HTTP 416 should be returned in this case.
231 #
232 # According to https://tools.ietf.org/html/rfc7233:
233 # If a valid byte-range-set includes at least one
234 # byte-range-spec with a first-byte-pos that is less than
235 # the current length of the representation, or at least one
236 # suffix-byte-range-spec with a non-zero suffix-length,
237 # then the byte-range-set is satisfiable. Otherwise, the
238 # byte-range-set is unsatisfiable.
239 self.headers[hdrs.CONTENT_RANGE] = f"bytes */{file_size}"
240 self.set_status(HTTPRequestRangeNotSatisfiable.status_code)
241 return await super().prepare(request)
243 status = HTTPPartialContent.status_code
244 # Even though you are sending the whole file, you should still
245 # return a HTTP 206 for a Range request.
246 self.set_status(status)
248 if ct:
249 self.content_type = ct
250 if encoding:
251 self.headers[hdrs.CONTENT_ENCODING] = encoding
252 if gzip:
253 self.headers[hdrs.VARY] = hdrs.ACCEPT_ENCODING
255 self.etag = etag_value # type: ignore[assignment]
256 self.last_modified = st.st_mtime # type: ignore[assignment]
257 self.content_length = count
259 self.headers[hdrs.ACCEPT_RANGES] = "bytes"
261 real_start = cast(int, start)
263 if status == HTTPPartialContent.status_code:
264 self.headers[hdrs.CONTENT_RANGE] = "bytes {}-{}/{}".format(
265 real_start, real_start + count - 1, file_size
266 )
268 # If we are sending 0 bytes calling sendfile() will throw a ValueError
269 if count == 0 or request.method == hdrs.METH_HEAD or self.status in [204, 304]:
270 return await super().prepare(request)
272 fobj = await loop.run_in_executor(None, filepath.open, "rb")
273 if start: # be aware that start could be None or int=0 here.
274 offset = start
275 else:
276 offset = 0
278 try:
279 return await self._sendfile(request, fobj, offset, count)
280 finally:
281 await asyncio.shield(loop.run_in_executor(None, fobj.close))