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

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) 

16 

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 

28 

29__all__ = ("FileResponse",) 

30 

31if TYPE_CHECKING: 

32 from .web_request import BaseRequest 

33 

34 

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

36 

37 

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

39 

40 

41class FileResponse(StreamResponse): 

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

43 

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) 

53 

54 self._path = pathlib.Path(path) 

55 self._chunk_size = chunk_size 

56 

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. 

62 

63 chunk_size = self._chunk_size 

64 loop = asyncio.get_event_loop() 

65 

66 await loop.run_in_executor(None, fobj.seek, offset) 

67 

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

75 

76 await writer.drain() 

77 return writer 

78 

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 

84 

85 if NOSENDFILE or self.compression: 

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

87 

88 loop = request._loop 

89 transport = request.transport 

90 assert transport is not None 

91 

92 try: 

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

94 except NotImplementedError: 

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

96 

97 await super().write_eof() 

98 return writer 

99 

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) 

105 

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) 

116 

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) 

123 

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. 

128 

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 

140 

141 return filepath, filepath.stat(), False 

142 

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 ) 

153 

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

155 last_modified = st.st_mtime 

156 

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) 

161 

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) 

169 

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) 

173 

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) 

181 

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 

189 

190 status = self._status 

191 file_size = st.st_size 

192 count = file_size 

193 

194 start = None 

195 

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) 

222 

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 ) 

244 

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) 

258 

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) 

263 

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 

274 

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

276 self.last_modified = st.st_mtime # type: ignore[assignment] 

277 self.content_length = count 

278 

279 self.headers[hdrs.ACCEPT_RANGES] = "bytes" 

280 

281 real_start = cast(int, start) 

282 

283 if status == HTTPPartialContent.status_code: 

284 self.headers[hdrs.CONTENT_RANGE] = "bytes {}-{}/{}".format( 

285 real_start, real_start + count - 1, file_size 

286 ) 

287 

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) 

291 

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 

297 

298 try: 

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

300 finally: 

301 await asyncio.shield(loop.run_in_executor(None, fobj.close))