Coverage for /pythoncovmergedfiles/medio/medio/src/aiohttp/aiohttp/web_fileresponse.py: 25%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1import asyncio
2import io
3import os
4import pathlib
5import sys
6from collections.abc import Awaitable, Callable
7from contextlib import suppress
8from enum import Enum, auto
9from mimetypes import MimeTypes
10from stat import S_ISREG
11from types import MappingProxyType
12from typing import IO, TYPE_CHECKING, Any, Final, Optional
14from . import hdrs
15from .abc import AbstractStreamWriter
16from .helpers import ETAG_ANY, ETag, must_be_empty_body
17from .typedefs import LooseHeaders, PathLike
18from .web_exceptions import (
19 HTTPForbidden,
20 HTTPNotFound,
21 HTTPNotModified,
22 HTTPPartialContent,
23 HTTPPreconditionFailed,
24 HTTPRequestRangeNotSatisfiable,
25)
26from .web_response import StreamResponse
28__all__ = ("FileResponse",)
30if TYPE_CHECKING:
31 from .web_request import BaseRequest
34_T_OnChunkSent = Optional[Callable[[bytes], Awaitable[None]]]
37NOSENDFILE: Final[bool] = bool(os.environ.get("AIOHTTP_NOSENDFILE"))
39CONTENT_TYPES: Final[MimeTypes] = MimeTypes()
41# File extension to IANA encodings map that will be checked in the order defined.
42ENCODING_EXTENSIONS = MappingProxyType(
43 {ext: CONTENT_TYPES.encodings_map[ext] for ext in (".br", ".gz")}
44)
46FALLBACK_CONTENT_TYPE = "application/octet-stream"
48# Provide additional MIME type/extension pairs to be recognized.
49# https://en.wikipedia.org/wiki/List_of_archive_formats#Compression_only
50ADDITIONAL_CONTENT_TYPES = MappingProxyType(
51 {
52 "application/gzip": ".gz",
53 "application/x-brotli": ".br",
54 "application/x-bzip2": ".bz2",
55 "application/x-compress": ".Z",
56 "application/x-xz": ".xz",
57 }
58)
61class _FileResponseResult(Enum):
62 """The result of the file response."""
64 SEND_FILE = auto() # Ie a regular file to send
65 NOT_ACCEPTABLE = auto() # Ie a socket, or non-regular file
66 PRE_CONDITION_FAILED = auto() # Ie If-Match or If-None-Match failed
67 NOT_MODIFIED = auto() # 304 Not Modified
70# Add custom pairs and clear the encodings map so guess_type ignores them.
71CONTENT_TYPES.encodings_map.clear()
72for content_type, extension in ADDITIONAL_CONTENT_TYPES.items():
73 CONTENT_TYPES.add_type(content_type, extension)
76_CLOSE_FUTURES: set[asyncio.Future[None]] = set()
79class FileResponse(StreamResponse):
80 """A response object can be used to send files."""
82 def __init__(
83 self,
84 path: PathLike,
85 chunk_size: int = 256 * 1024,
86 status: int = 200,
87 reason: str | None = None,
88 headers: LooseHeaders | None = None,
89 ) -> None:
90 super().__init__(status=status, reason=reason, headers=headers)
92 self._path = pathlib.Path(path)
93 self._chunk_size = chunk_size
95 def _seek_and_read(self, fobj: IO[Any], offset: int, chunk_size: int) -> bytes:
96 fobj.seek(offset)
97 return fobj.read(chunk_size) # type: ignore[no-any-return]
99 async def _sendfile_fallback(
100 self, writer: AbstractStreamWriter, fobj: IO[Any], offset: int, count: int
101 ) -> AbstractStreamWriter:
102 # To keep memory usage low,fobj is transferred in chunks
103 # controlled by the constructor's chunk_size argument.
105 chunk_size = self._chunk_size
106 loop = asyncio.get_event_loop()
107 chunk = await loop.run_in_executor(
108 None, self._seek_and_read, fobj, offset, min(chunk_size, count)
109 )
110 while chunk:
111 await writer.write(chunk)
112 count = count - len(chunk)
113 if count <= 0:
114 break
115 chunk = await loop.run_in_executor(None, fobj.read, min(chunk_size, count))
117 await writer.drain()
118 return writer
120 async def _sendfile(
121 self, request: "BaseRequest", fobj: IO[Any], offset: int, count: int
122 ) -> AbstractStreamWriter:
123 writer = await super().prepare(request)
124 assert writer is not None
126 if NOSENDFILE or self.compression:
127 return await self._sendfile_fallback(writer, fobj, offset, count)
129 loop = request._loop
130 transport = request.transport
131 if transport is None:
132 raise ConnectionResetError("Connection lost")
134 try:
135 await loop.sendfile(transport, fobj, offset, count)
136 except NotImplementedError:
137 return await self._sendfile_fallback(writer, fobj, offset, count)
139 await super().write_eof()
140 return writer
142 @staticmethod
143 def _etag_match(etag_value: str, etags: tuple[ETag, ...], *, weak: bool) -> bool:
144 if len(etags) == 1 and etags[0].value == ETAG_ANY:
145 return True
146 return any(
147 etag.value == etag_value for etag in etags if weak or not etag.is_weak
148 )
150 async def _not_modified(
151 self, request: "BaseRequest", etag_value: str, last_modified: float
152 ) -> AbstractStreamWriter | None:
153 self.set_status(HTTPNotModified.status_code)
154 self._length_check = False
155 self.etag = etag_value
156 self.last_modified = last_modified
157 # Delete any Content-Length headers provided by user. HTTP 304
158 # should always have empty response body
159 return await super().prepare(request)
161 async def _precondition_failed(
162 self, request: "BaseRequest"
163 ) -> AbstractStreamWriter | None:
164 self.set_status(HTTPPreconditionFailed.status_code)
165 self.content_length = 0
166 return await super().prepare(request)
168 def _make_response(
169 self, request: "BaseRequest", accept_encoding: str
170 ) -> tuple[
171 _FileResponseResult, io.BufferedReader | None, os.stat_result, str | None
172 ]:
173 """Return the response result, io object, stat result, and encoding.
175 If an uncompressed file is returned, the encoding is set to
176 :py:data:`None`.
178 This method should be called from a thread executor
179 since it calls os.stat which may block.
180 """
181 file_path, st, file_encoding = self._get_file_path_stat_encoding(
182 accept_encoding
183 )
184 if not file_path:
185 return _FileResponseResult.NOT_ACCEPTABLE, None, st, None
187 etag_value = f"{st.st_mtime_ns:x}-{st.st_size:x}"
189 # https://www.rfc-editor.org/rfc/rfc9110#section-13.1.1-2
190 if (ifmatch := request.if_match) is not None and not self._etag_match(
191 etag_value, ifmatch, weak=False
192 ):
193 return _FileResponseResult.PRE_CONDITION_FAILED, None, st, file_encoding
195 if (
196 (unmodsince := request.if_unmodified_since) is not None
197 and ifmatch is None
198 and st.st_mtime > unmodsince.timestamp()
199 ):
200 return _FileResponseResult.PRE_CONDITION_FAILED, None, st, file_encoding
202 # https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2-2
203 if (ifnonematch := request.if_none_match) is not None and self._etag_match(
204 etag_value, ifnonematch, weak=True
205 ):
206 return _FileResponseResult.NOT_MODIFIED, None, st, file_encoding
208 if (
209 (modsince := request.if_modified_since) is not None
210 and ifnonematch is None
211 and st.st_mtime <= modsince.timestamp()
212 ):
213 return _FileResponseResult.NOT_MODIFIED, None, st, file_encoding
215 fobj = file_path.open("rb")
216 with suppress(OSError):
217 # fstat() may not be available on all platforms
218 # Once we open the file, we want the fstat() to ensure
219 # the file has not changed between the first stat()
220 # and the open().
221 st = os.stat(fobj.fileno())
222 return _FileResponseResult.SEND_FILE, fobj, st, file_encoding
224 def _get_file_path_stat_encoding(
225 self, accept_encoding: str
226 ) -> tuple[pathlib.Path | None, os.stat_result, str | None]:
227 file_path = self._path
228 for file_extension, file_encoding in ENCODING_EXTENSIONS.items():
229 if file_encoding not in accept_encoding:
230 continue
232 compressed_path = file_path.with_suffix(file_path.suffix + file_extension)
233 with suppress(OSError):
234 # Do not follow symlinks and ignore any non-regular files.
235 st = compressed_path.lstat()
236 if S_ISREG(st.st_mode):
237 return compressed_path, st, file_encoding
239 # Fallback to the uncompressed file
240 st = file_path.stat()
241 return file_path if S_ISREG(st.st_mode) else None, st, None
243 async def prepare(self, request: "BaseRequest") -> AbstractStreamWriter | None:
244 loop = asyncio.get_running_loop()
245 # Encoding comparisons should be case-insensitive
246 # https://www.rfc-editor.org/rfc/rfc9110#section-8.4.1
247 accept_encoding = request.headers.get(hdrs.ACCEPT_ENCODING, "").lower()
248 try:
249 response_result, fobj, st, file_encoding = await loop.run_in_executor(
250 None, self._make_response, request, accept_encoding
251 )
252 except PermissionError:
253 self.set_status(HTTPForbidden.status_code)
254 return await super().prepare(request)
255 except OSError:
256 # Most likely to be FileNotFoundError or OSError for circular
257 # symlinks in python >= 3.13, so respond with 404.
258 self.set_status(HTTPNotFound.status_code)
259 return await super().prepare(request)
261 # Forbid special files like sockets, pipes, devices, etc.
262 if response_result is _FileResponseResult.NOT_ACCEPTABLE:
263 self.set_status(HTTPForbidden.status_code)
264 return await super().prepare(request)
266 if response_result is _FileResponseResult.PRE_CONDITION_FAILED:
267 return await self._precondition_failed(request)
269 if response_result is _FileResponseResult.NOT_MODIFIED:
270 etag_value = f"{st.st_mtime_ns:x}-{st.st_size:x}"
271 last_modified = st.st_mtime
272 return await self._not_modified(request, etag_value, last_modified)
274 assert fobj is not None
275 try:
276 return await self._prepare_open_file(request, fobj, st, file_encoding)
277 finally:
278 # We do not await here because we do not want to wait
279 # for the executor to finish before returning the response
280 # so the connection can begin servicing another request
281 # as soon as possible.
282 close_future = loop.run_in_executor(None, fobj.close)
283 # Hold a strong reference to the future to prevent it from being
284 # garbage collected before it completes.
285 _CLOSE_FUTURES.add(close_future)
286 close_future.add_done_callback(_CLOSE_FUTURES.remove)
288 async def _prepare_open_file(
289 self,
290 request: "BaseRequest",
291 fobj: io.BufferedReader,
292 st: os.stat_result,
293 file_encoding: str | None,
294 ) -> AbstractStreamWriter | None:
295 status = self._status
296 file_size: int = st.st_size
297 file_mtime: float = st.st_mtime
298 count: int = file_size
299 start: int | None = None
301 if (ifrange := request.if_range) is None or file_mtime <= ifrange.timestamp():
302 # If-Range header check:
303 # condition = cached date >= last modification date
304 # return 206 if True else 200.
305 # if False:
306 # Range header would not be processed, return 200
307 # if True but Range header missing
308 # return 200
309 try:
310 rng = request.http_range
311 start = rng.start
312 end: int | None = rng.stop
313 except ValueError:
314 # https://tools.ietf.org/html/rfc7233:
315 # A server generating a 416 (Range Not Satisfiable) response to
316 # a byte-range request SHOULD send a Content-Range header field
317 # with an unsatisfied-range value.
318 # The complete-length in a 416 response indicates the current
319 # length of the selected representation.
320 #
321 # Will do the same below. Many servers ignore this and do not
322 # send a Content-Range header with HTTP 416
323 self._headers[hdrs.CONTENT_RANGE] = f"bytes */{file_size}"
324 self.set_status(HTTPRequestRangeNotSatisfiable.status_code)
325 return await super().prepare(request)
327 # If a range request has been made, convert start, end slice
328 # notation into file pointer offset and count
329 if start is not None:
330 if start < 0 and end is None: # return tail of file
331 start += file_size
332 if start < 0:
333 # if Range:bytes=-1000 in request header but file size
334 # is only 200, there would be trouble without this
335 start = 0
336 count = file_size - start
337 else:
338 # rfc7233:If the last-byte-pos value is
339 # absent, or if the value is greater than or equal to
340 # the current length of the representation data,
341 # the byte range is interpreted as the remainder
342 # of the representation (i.e., the server replaces the
343 # value of last-byte-pos with a value that is one less than
344 # the current length of the selected representation).
345 count = (
346 min(end if end is not None else file_size, file_size) - start
347 )
349 if start >= file_size:
350 # HTTP 416 should be returned in this case.
351 #
352 # According to https://tools.ietf.org/html/rfc7233:
353 # If a valid byte-range-set includes at least one
354 # byte-range-spec with a first-byte-pos that is less than
355 # the current length of the representation, or at least one
356 # suffix-byte-range-spec with a non-zero suffix-length,
357 # then the byte-range-set is satisfiable. Otherwise, the
358 # byte-range-set is unsatisfiable.
359 self._headers[hdrs.CONTENT_RANGE] = f"bytes */{file_size}"
360 self.set_status(HTTPRequestRangeNotSatisfiable.status_code)
361 return await super().prepare(request)
363 status = HTTPPartialContent.status_code
364 # Even though you are sending the whole file, you should still
365 # return a HTTP 206 for a Range request.
366 self.set_status(status)
368 # If the Content-Type header is not already set, guess it based on the
369 # extension of the request path. The encoding returned by guess_type
370 # can be ignored since the map was cleared above.
371 if hdrs.CONTENT_TYPE not in self._headers:
372 if sys.version_info >= (3, 13):
373 guesser = CONTENT_TYPES.guess_file_type
374 else:
375 guesser = CONTENT_TYPES.guess_type
376 self.content_type = guesser(self._path)[0] or FALLBACK_CONTENT_TYPE
378 if file_encoding:
379 self._headers[hdrs.CONTENT_ENCODING] = file_encoding
380 self._headers[hdrs.VARY] = hdrs.ACCEPT_ENCODING
381 # Disable compression if we are already sending
382 # a compressed file since we don't want to double
383 # compress.
384 self._compression = False
386 self.etag = f"{st.st_mtime_ns:x}-{st.st_size:x}"
387 self.last_modified = file_mtime
388 self.content_length = count
390 self._headers[hdrs.ACCEPT_RANGES] = "bytes"
392 if status == HTTPPartialContent.status_code:
393 real_start = start
394 assert real_start is not None
395 self._headers[hdrs.CONTENT_RANGE] = (
396 f"bytes {real_start}-{real_start + count - 1}/{file_size}"
397 )
399 # If we are sending 0 bytes calling sendfile() will throw a ValueError
400 if count == 0 or must_be_empty_body(request.method, status):
401 return await super().prepare(request)
403 # be aware that start could be None or int=0 here.
404 offset = start or 0
406 return await self._sendfile(request, fobj, offset, count)