Coverage for /pythoncovmergedfiles/medio/medio/src/aiohttp/aiohttp/web_fileresponse.py: 17%
146 statements
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-09 06:47 +0000
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-09 06:47 +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, must_be_empty_body
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:
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 def _get_file_path_stat_and_gzip(
125 self, check_for_gzipped_file: bool
126 ) -> Tuple[pathlib.Path, os.stat_result, bool]:
127 """Return the file path, stat result, and gzip status.
129 This method should be called from a thread executor
130 since it calls os.stat which may block.
131 """
132 filepath = self._path
133 if check_for_gzipped_file:
134 gzip_path = filepath.with_name(filepath.name + ".gz")
135 try:
136 return gzip_path, gzip_path.stat(), True
137 except OSError:
138 # Fall through and try the non-gzipped file
139 pass
141 return filepath, filepath.stat(), False
143 async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter]:
144 loop = asyncio.get_event_loop()
145 # Encoding comparisons should be case-insensitive
146 # https://www.rfc-editor.org/rfc/rfc9110#section-8.4.1
147 check_for_gzipped_file = (
148 "gzip" in request.headers.get(hdrs.ACCEPT_ENCODING, "").lower()
149 )
150 filepath, st, gzip = await loop.run_in_executor(
151 None, self._get_file_path_stat_and_gzip, check_for_gzipped_file
152 )
154 etag_value = f"{st.st_mtime_ns:x}-{st.st_size:x}"
155 last_modified = st.st_mtime
157 # https://tools.ietf.org/html/rfc7232#section-6
158 ifmatch = request.if_match
159 if ifmatch is not None and not self._strong_etag_match(etag_value, ifmatch):
160 return await self._precondition_failed(request)
162 unmodsince = request.if_unmodified_since
163 if (
164 unmodsince is not None
165 and ifmatch is None
166 and st.st_mtime > unmodsince.timestamp()
167 ):
168 return await self._precondition_failed(request)
170 ifnonematch = request.if_none_match
171 if ifnonematch is not None and self._strong_etag_match(etag_value, ifnonematch):
172 return await self._not_modified(request, etag_value, last_modified)
174 modsince = request.if_modified_since
175 if (
176 modsince is not None
177 and ifnonematch is None
178 and st.st_mtime <= modsince.timestamp()
179 ):
180 return await self._not_modified(request, etag_value, last_modified)
182 ct = None
183 if hdrs.CONTENT_TYPE not in self.headers:
184 ct, encoding = mimetypes.guess_type(str(filepath))
185 if not ct:
186 ct = "application/octet-stream"
187 else:
188 encoding = "gzip" if gzip else None
190 status = self._status
191 file_size = st.st_size
192 count = file_size
194 start = None
196 ifrange = request.if_range
197 if ifrange is None or st.st_mtime <= ifrange.timestamp():
198 # If-Range header check:
199 # condition = cached date >= last modification date
200 # return 206 if True else 200.
201 # if False:
202 # Range header would not be processed, return 200
203 # if True but Range header missing
204 # return 200
205 try:
206 rng = request.http_range
207 start = rng.start
208 end = rng.stop
209 except ValueError:
210 # https://tools.ietf.org/html/rfc7233:
211 # A server generating a 416 (Range Not Satisfiable) response to
212 # a byte-range request SHOULD send a Content-Range header field
213 # with an unsatisfied-range value.
214 # The complete-length in a 416 response indicates the current
215 # length of the selected representation.
216 #
217 # Will do the same below. Many servers ignore this and do not
218 # send a Content-Range header with HTTP 416
219 self.headers[hdrs.CONTENT_RANGE] = f"bytes */{file_size}"
220 self.set_status(HTTPRequestRangeNotSatisfiable.status_code)
221 return await super().prepare(request)
223 # If a range request has been made, convert start, end slice
224 # notation into file pointer offset and count
225 if start is not None or end is not None:
226 if start < 0 and end is None: # return tail of file
227 start += file_size
228 if start < 0:
229 # if Range:bytes=-1000 in request header but file size
230 # is only 200, there would be trouble without this
231 start = 0
232 count = file_size - start
233 else:
234 # rfc7233:If the last-byte-pos value is
235 # absent, or if the value is greater than or equal to
236 # the current length of the representation data,
237 # the byte range is interpreted as the remainder
238 # of the representation (i.e., the server replaces the
239 # value of last-byte-pos with a value that is one less than
240 # the current length of the selected representation).
241 count = (
242 min(end if end is not None else file_size, file_size) - start
243 )
245 if start >= file_size:
246 # HTTP 416 should be returned in this case.
247 #
248 # According to https://tools.ietf.org/html/rfc7233:
249 # If a valid byte-range-set includes at least one
250 # byte-range-spec with a first-byte-pos that is less than
251 # the current length of the representation, or at least one
252 # suffix-byte-range-spec with a non-zero suffix-length,
253 # then the byte-range-set is satisfiable. Otherwise, the
254 # byte-range-set is unsatisfiable.
255 self.headers[hdrs.CONTENT_RANGE] = f"bytes */{file_size}"
256 self.set_status(HTTPRequestRangeNotSatisfiable.status_code)
257 return await super().prepare(request)
259 status = HTTPPartialContent.status_code
260 # Even though you are sending the whole file, you should still
261 # return a HTTP 206 for a Range request.
262 self.set_status(status)
264 if ct:
265 self.content_type = ct
266 if encoding:
267 self.headers[hdrs.CONTENT_ENCODING] = encoding
268 if gzip:
269 self.headers[hdrs.VARY] = hdrs.ACCEPT_ENCODING
270 # Disable compression if we are already sending
271 # a compressed file since we don't want to double
272 # compress.
273 self._compression = False
275 self.etag = etag_value # type: ignore[assignment]
276 self.last_modified = st.st_mtime # type: ignore[assignment]
277 self.content_length = count
279 self.headers[hdrs.ACCEPT_RANGES] = "bytes"
281 real_start = cast(int, start)
283 if status == HTTPPartialContent.status_code:
284 self.headers[hdrs.CONTENT_RANGE] = "bytes {}-{}/{}".format(
285 real_start, real_start + count - 1, file_size
286 )
288 # If we are sending 0 bytes calling sendfile() will throw a ValueError
289 if count == 0 or must_be_empty_body(request.method, self.status):
290 return await super().prepare(request)
292 fobj = await loop.run_in_executor(None, filepath.open, "rb")
293 if start: # be aware that start could be None or int=0 here.
294 offset = start
295 else:
296 offset = 0
298 try:
299 return await self._sendfile(request, fobj, offset, count)
300 finally:
301 await asyncio.shield(loop.run_in_executor(None, fobj.close))