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