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

188 statements  

1import asyncio 

2import io 

3import os 

4import pathlib 

5import sys 

6from contextlib import suppress 

7from enum import Enum, auto 

8from mimetypes import MimeTypes 

9from stat import S_ISREG 

10from types import MappingProxyType 

11from typing import ( 

12 IO, 

13 TYPE_CHECKING, 

14 Any, 

15 Awaitable, 

16 Callable, 

17 Final, 

18 Optional, 

19 Set, 

20 Tuple, 

21) 

22 

23from . import hdrs 

24from .abc import AbstractStreamWriter 

25from .helpers import ETAG_ANY, ETag, must_be_empty_body 

26from .typedefs import LooseHeaders, PathLike 

27from .web_exceptions import ( 

28 HTTPForbidden, 

29 HTTPNotFound, 

30 HTTPNotModified, 

31 HTTPPartialContent, 

32 HTTPPreconditionFailed, 

33 HTTPRequestRangeNotSatisfiable, 

34) 

35from .web_response import StreamResponse 

36 

37__all__ = ("FileResponse",) 

38 

39if TYPE_CHECKING: 

40 from .web_request import BaseRequest 

41 

42 

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

44 

45 

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

47 

48CONTENT_TYPES: Final[MimeTypes] = MimeTypes() 

49 

50# File extension to IANA encodings map that will be checked in the order defined. 

51ENCODING_EXTENSIONS = MappingProxyType( 

52 {ext: CONTENT_TYPES.encodings_map[ext] for ext in (".br", ".gz")} 

53) 

54 

55FALLBACK_CONTENT_TYPE = "application/octet-stream" 

56 

57# Provide additional MIME type/extension pairs to be recognized. 

58# https://en.wikipedia.org/wiki/List_of_archive_formats#Compression_only 

59ADDITIONAL_CONTENT_TYPES = MappingProxyType( 

60 { 

61 "application/gzip": ".gz", 

62 "application/x-brotli": ".br", 

63 "application/x-bzip2": ".bz2", 

64 "application/x-compress": ".Z", 

65 "application/x-xz": ".xz", 

66 } 

67) 

68 

69 

70class _FileResponseResult(Enum): 

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

72 

73 SEND_FILE = auto() # Ie a regular file to send 

74 NOT_ACCEPTABLE = auto() # Ie a socket, or non-regular file 

75 PRE_CONDITION_FAILED = auto() # Ie If-Match or If-None-Match failed 

76 NOT_MODIFIED = auto() # 304 Not Modified 

77 

78 

79# Add custom pairs and clear the encodings map so guess_type ignores them. 

80CONTENT_TYPES.encodings_map.clear() 

81for content_type, extension in ADDITIONAL_CONTENT_TYPES.items(): 

82 CONTENT_TYPES.add_type(content_type, extension) 

83 

84 

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

86 

87 

88class FileResponse(StreamResponse): 

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

90 

91 def __init__( 

92 self, 

93 path: PathLike, 

94 chunk_size: int = 256 * 1024, 

95 status: int = 200, 

96 reason: Optional[str] = None, 

97 headers: Optional[LooseHeaders] = None, 

98 ) -> None: 

99 super().__init__(status=status, reason=reason, headers=headers) 

100 

101 self._path = pathlib.Path(path) 

102 self._chunk_size = chunk_size 

103 

104 def _seek_and_read(self, fobj: IO[Any], offset: int, chunk_size: int) -> bytes: 

105 fobj.seek(offset) 

106 return fobj.read(chunk_size) # type: ignore[no-any-return] 

107 

108 async def _sendfile_fallback( 

109 self, writer: AbstractStreamWriter, fobj: IO[Any], offset: int, count: int 

110 ) -> AbstractStreamWriter: 

111 # To keep memory usage low,fobj is transferred in chunks 

112 # controlled by the constructor's chunk_size argument. 

113 

114 chunk_size = self._chunk_size 

115 loop = asyncio.get_event_loop() 

116 chunk = await loop.run_in_executor( 

117 None, self._seek_and_read, fobj, offset, chunk_size 

118 ) 

119 while chunk: 

120 await writer.write(chunk) 

121 count = count - chunk_size 

122 if count <= 0: 

123 break 

124 chunk = await loop.run_in_executor(None, fobj.read, min(chunk_size, count)) 

125 

126 await writer.drain() 

127 return writer 

128 

129 async def _sendfile( 

130 self, request: "BaseRequest", fobj: IO[Any], offset: int, count: int 

131 ) -> AbstractStreamWriter: 

132 writer = await super().prepare(request) 

133 assert writer is not None 

134 

135 if NOSENDFILE or self.compression: 

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

137 

138 loop = request._loop 

139 transport = request.transport 

140 assert transport is not None 

141 

142 try: 

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

144 except NotImplementedError: 

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

146 

147 await super().write_eof() 

148 return writer 

149 

150 @staticmethod 

151 def _etag_match(etag_value: str, etags: Tuple[ETag, ...], *, weak: bool) -> bool: 

152 if len(etags) == 1 and etags[0].value == ETAG_ANY: 

153 return True 

154 return any( 

155 etag.value == etag_value for etag in etags if weak or not etag.is_weak 

156 ) 

157 

158 async def _not_modified( 

159 self, request: "BaseRequest", etag_value: str, last_modified: float 

160 ) -> Optional[AbstractStreamWriter]: 

161 self.set_status(HTTPNotModified.status_code) 

162 self._length_check = False 

163 self.etag = etag_value # type: ignore[assignment] 

164 self.last_modified = last_modified # type: ignore[assignment] 

165 # Delete any Content-Length headers provided by user. HTTP 304 

166 # should always have empty response body 

167 return await super().prepare(request) 

168 

169 async def _precondition_failed( 

170 self, request: "BaseRequest" 

171 ) -> Optional[AbstractStreamWriter]: 

172 self.set_status(HTTPPreconditionFailed.status_code) 

173 self.content_length = 0 

174 return await super().prepare(request) 

175 

176 def _make_response( 

177 self, request: "BaseRequest", accept_encoding: str 

178 ) -> Tuple[ 

179 _FileResponseResult, Optional[io.BufferedReader], os.stat_result, Optional[str] 

180 ]: 

181 """Return the response result, io object, stat result, and encoding. 

182 

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

184 :py:data:`None`. 

185 

186 This method should be called from a thread executor 

187 since it calls os.stat which may block. 

188 """ 

189 file_path, st, file_encoding = self._get_file_path_stat_encoding( 

190 accept_encoding 

191 ) 

192 if not file_path: 

193 return _FileResponseResult.NOT_ACCEPTABLE, None, st, None 

194 

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

196 

197 # https://www.rfc-editor.org/rfc/rfc9110#section-13.1.1-2 

198 if (ifmatch := request.if_match) is not None and not self._etag_match( 

199 etag_value, ifmatch, weak=False 

200 ): 

201 return _FileResponseResult.PRE_CONDITION_FAILED, None, st, file_encoding 

202 

203 if ( 

204 (unmodsince := request.if_unmodified_since) is not None 

205 and ifmatch is None 

206 and st.st_mtime > unmodsince.timestamp() 

207 ): 

208 return _FileResponseResult.PRE_CONDITION_FAILED, None, st, file_encoding 

209 

210 # https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2-2 

211 if (ifnonematch := request.if_none_match) is not None and self._etag_match( 

212 etag_value, ifnonematch, weak=True 

213 ): 

214 return _FileResponseResult.NOT_MODIFIED, None, st, file_encoding 

215 

216 if ( 

217 (modsince := request.if_modified_since) is not None 

218 and ifnonematch is None 

219 and st.st_mtime <= modsince.timestamp() 

220 ): 

221 return _FileResponseResult.NOT_MODIFIED, None, st, file_encoding 

222 

223 fobj = file_path.open("rb") 

224 with suppress(OSError): 

225 # fstat() may not be available on all platforms 

226 # Once we open the file, we want the fstat() to ensure 

227 # the file has not changed between the first stat() 

228 # and the open(). 

229 st = os.stat(fobj.fileno()) 

230 return _FileResponseResult.SEND_FILE, fobj, st, file_encoding 

231 

232 def _get_file_path_stat_encoding( 

233 self, accept_encoding: str 

234 ) -> Tuple[Optional[pathlib.Path], os.stat_result, Optional[str]]: 

235 file_path = self._path 

236 for file_extension, file_encoding in ENCODING_EXTENSIONS.items(): 

237 if file_encoding not in accept_encoding: 

238 continue 

239 

240 compressed_path = file_path.with_suffix(file_path.suffix + file_extension) 

241 with suppress(OSError): 

242 # Do not follow symlinks and ignore any non-regular files. 

243 st = compressed_path.lstat() 

244 if S_ISREG(st.st_mode): 

245 return compressed_path, st, file_encoding 

246 

247 # Fallback to the uncompressed file 

248 st = file_path.stat() 

249 return file_path if S_ISREG(st.st_mode) else None, st, None 

250 

251 async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter]: 

252 loop = asyncio.get_running_loop() 

253 # Encoding comparisons should be case-insensitive 

254 # https://www.rfc-editor.org/rfc/rfc9110#section-8.4.1 

255 accept_encoding = request.headers.get(hdrs.ACCEPT_ENCODING, "").lower() 

256 try: 

257 response_result, fobj, st, file_encoding = await loop.run_in_executor( 

258 None, self._make_response, request, accept_encoding 

259 ) 

260 except PermissionError: 

261 self.set_status(HTTPForbidden.status_code) 

262 return await super().prepare(request) 

263 except OSError: 

264 # Most likely to be FileNotFoundError or OSError for circular 

265 # symlinks in python >= 3.13, so respond with 404. 

266 self.set_status(HTTPNotFound.status_code) 

267 return await super().prepare(request) 

268 

269 # Forbid special files like sockets, pipes, devices, etc. 

270 if response_result is _FileResponseResult.NOT_ACCEPTABLE: 

271 self.set_status(HTTPForbidden.status_code) 

272 return await super().prepare(request) 

273 

274 if response_result is _FileResponseResult.PRE_CONDITION_FAILED: 

275 return await self._precondition_failed(request) 

276 

277 if response_result is _FileResponseResult.NOT_MODIFIED: 

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

279 last_modified = st.st_mtime 

280 return await self._not_modified(request, etag_value, last_modified) 

281 

282 assert fobj is not None 

283 try: 

284 return await self._prepare_open_file(request, fobj, st, file_encoding) 

285 finally: 

286 # We do not await here because we do not want to wait 

287 # for the executor to finish before returning the response 

288 # so the connection can begin servicing another request 

289 # as soon as possible. 

290 close_future = loop.run_in_executor(None, fobj.close) 

291 # Hold a strong reference to the future to prevent it from being 

292 # garbage collected before it completes. 

293 _CLOSE_FUTURES.add(close_future) 

294 close_future.add_done_callback(_CLOSE_FUTURES.remove) 

295 

296 async def _prepare_open_file( 

297 self, 

298 request: "BaseRequest", 

299 fobj: io.BufferedReader, 

300 st: os.stat_result, 

301 file_encoding: Optional[str], 

302 ) -> Optional[AbstractStreamWriter]: 

303 status = self._status 

304 file_size: int = st.st_size 

305 file_mtime: float = st.st_mtime 

306 count: int = file_size 

307 start: Optional[int] = None 

308 

309 if (ifrange := request.if_range) is None or file_mtime <= ifrange.timestamp(): 

310 # If-Range header check: 

311 # condition = cached date >= last modification date 

312 # return 206 if True else 200. 

313 # if False: 

314 # Range header would not be processed, return 200 

315 # if True but Range header missing 

316 # return 200 

317 try: 

318 rng = request.http_range 

319 start = rng.start 

320 end: Optional[int] = rng.stop 

321 except ValueError: 

322 # https://tools.ietf.org/html/rfc7233: 

323 # A server generating a 416 (Range Not Satisfiable) response to 

324 # a byte-range request SHOULD send a Content-Range header field 

325 # with an unsatisfied-range value. 

326 # The complete-length in a 416 response indicates the current 

327 # length of the selected representation. 

328 # 

329 # Will do the same below. Many servers ignore this and do not 

330 # send a Content-Range header with HTTP 416 

331 self._headers[hdrs.CONTENT_RANGE] = f"bytes */{file_size}" 

332 self.set_status(HTTPRequestRangeNotSatisfiable.status_code) 

333 return await super().prepare(request) 

334 

335 # If a range request has been made, convert start, end slice 

336 # notation into file pointer offset and count 

337 if start is not None: 

338 if start < 0 and end is None: # return tail of file 

339 start += file_size 

340 if start < 0: 

341 # if Range:bytes=-1000 in request header but file size 

342 # is only 200, there would be trouble without this 

343 start = 0 

344 count = file_size - start 

345 else: 

346 # rfc7233:If the last-byte-pos value is 

347 # absent, or if the value is greater than or equal to 

348 # the current length of the representation data, 

349 # the byte range is interpreted as the remainder 

350 # of the representation (i.e., the server replaces the 

351 # value of last-byte-pos with a value that is one less than 

352 # the current length of the selected representation). 

353 count = ( 

354 min(end if end is not None else file_size, file_size) - start 

355 ) 

356 

357 if start >= file_size: 

358 # HTTP 416 should be returned in this case. 

359 # 

360 # According to https://tools.ietf.org/html/rfc7233: 

361 # If a valid byte-range-set includes at least one 

362 # byte-range-spec with a first-byte-pos that is less than 

363 # the current length of the representation, or at least one 

364 # suffix-byte-range-spec with a non-zero suffix-length, 

365 # then the byte-range-set is satisfiable. Otherwise, the 

366 # byte-range-set is unsatisfiable. 

367 self._headers[hdrs.CONTENT_RANGE] = f"bytes */{file_size}" 

368 self.set_status(HTTPRequestRangeNotSatisfiable.status_code) 

369 return await super().prepare(request) 

370 

371 status = HTTPPartialContent.status_code 

372 # Even though you are sending the whole file, you should still 

373 # return a HTTP 206 for a Range request. 

374 self.set_status(status) 

375 

376 # If the Content-Type header is not already set, guess it based on the 

377 # extension of the request path. The encoding returned by guess_type 

378 # can be ignored since the map was cleared above. 

379 if hdrs.CONTENT_TYPE not in self._headers: 

380 if sys.version_info >= (3, 13): 

381 guesser = CONTENT_TYPES.guess_file_type 

382 else: 

383 guesser = CONTENT_TYPES.guess_type 

384 self.content_type = guesser(self._path)[0] or FALLBACK_CONTENT_TYPE 

385 

386 if file_encoding: 

387 self._headers[hdrs.CONTENT_ENCODING] = file_encoding 

388 self._headers[hdrs.VARY] = hdrs.ACCEPT_ENCODING 

389 # Disable compression if we are already sending 

390 # a compressed file since we don't want to double 

391 # compress. 

392 self._compression = False 

393 

394 self.etag = f"{st.st_mtime_ns:x}-{st.st_size:x}" # type: ignore[assignment] 

395 self.last_modified = file_mtime # type: ignore[assignment] 

396 self.content_length = count 

397 

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

399 

400 if status == HTTPPartialContent.status_code: 

401 real_start = start 

402 assert real_start is not None 

403 self._headers[hdrs.CONTENT_RANGE] = "bytes {}-{}/{}".format( 

404 real_start, real_start + count - 1, file_size 

405 ) 

406 

407 # If we are sending 0 bytes calling sendfile() will throw a ValueError 

408 if count == 0 or must_be_empty_body(request.method, status): 

409 return await super().prepare(request) 

410 

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

412 offset = start or 0 

413 

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