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

190 statements  

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 

13 

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 

27 

28__all__ = ("FileResponse",) 

29 

30if TYPE_CHECKING: 

31 from .web_request import BaseRequest 

32 

33 

34_T_OnChunkSent = Optional[Callable[[bytes], Awaitable[None]]] 

35 

36 

37NOSENDFILE: Final[bool] = bool(os.environ.get("AIOHTTP_NOSENDFILE")) 

38 

39CONTENT_TYPES: Final[MimeTypes] = MimeTypes() 

40 

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) 

45 

46FALLBACK_CONTENT_TYPE = "application/octet-stream" 

47 

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) 

59 

60 

61class _FileResponseResult(Enum): 

62 """The result of the file response.""" 

63 

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 

68 

69 

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) 

74 

75 

76_CLOSE_FUTURES: set[asyncio.Future[None]] = set() 

77 

78 

79class FileResponse(StreamResponse): 

80 """A response object can be used to send files.""" 

81 

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) 

91 

92 self._path = pathlib.Path(path) 

93 self._chunk_size = chunk_size 

94 

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] 

98 

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. 

104 

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)) 

116 

117 await writer.drain() 

118 return writer 

119 

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 

125 

126 if NOSENDFILE or self.compression: 

127 return await self._sendfile_fallback(writer, fobj, offset, count) 

128 

129 loop = request._loop 

130 transport = request.transport 

131 if transport is None: 

132 raise ConnectionResetError("Connection lost") 

133 

134 try: 

135 await loop.sendfile(transport, fobj, offset, count) 

136 except NotImplementedError: 

137 return await self._sendfile_fallback(writer, fobj, offset, count) 

138 

139 await super().write_eof() 

140 return writer 

141 

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 ) 

149 

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) 

160 

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) 

167 

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. 

174 

175 If an uncompressed file is returned, the encoding is set to 

176 :py:data:`None`. 

177 

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 

186 

187 etag_value = f"{st.st_mtime_ns:x}-{st.st_size:x}" 

188 

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 

194 

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 

201 

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 

207 

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 

214 

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 

223 

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 

231 

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 

238 

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 

242 

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) 

260 

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) 

265 

266 if response_result is _FileResponseResult.PRE_CONDITION_FAILED: 

267 return await self._precondition_failed(request) 

268 

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) 

273 

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) 

287 

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 

300 

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) 

326 

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 ) 

348 

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) 

362 

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) 

367 

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 

377 

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 

385 

386 self.etag = f"{st.st_mtime_ns:x}-{st.st_size:x}" 

387 self.last_modified = file_mtime 

388 self.content_length = count 

389 

390 self._headers[hdrs.ACCEPT_RANGES] = "bytes" 

391 

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 ) 

398 

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) 

402 

403 # be aware that start could be None or int=0 here. 

404 offset = start or 0 

405 

406 return await self._sendfile(request, fobj, offset, count)