Coverage for /pythoncovmergedfiles/medio/medio/src/aiohttp/aiohttp/web_fileresponse.py: 16%

140 statements  

« prev     ^ index     » next       coverage.py v7.3.1, created at 2023-09-27 06:09 +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 

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: # pragma: no cover 

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 async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter]: 

125 filepath = self._path 

126 

127 gzip = False 

128 if "gzip" in request.headers.get(hdrs.ACCEPT_ENCODING, ""): 

129 gzip_path = filepath.with_name(filepath.name + ".gz") 

130 

131 if gzip_path.is_file(): 

132 filepath = gzip_path 

133 gzip = True 

134 

135 loop = asyncio.get_event_loop() 

136 st: os.stat_result = await loop.run_in_executor(None, filepath.stat) 

137 

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

139 last_modified = st.st_mtime 

140 

141 # https://tools.ietf.org/html/rfc7232#section-6 

142 ifmatch = request.if_match 

143 if ifmatch is not None and not self._strong_etag_match(etag_value, ifmatch): 

144 return await self._precondition_failed(request) 

145 

146 unmodsince = request.if_unmodified_since 

147 if ( 

148 unmodsince is not None 

149 and ifmatch is None 

150 and st.st_mtime > unmodsince.timestamp() 

151 ): 

152 return await self._precondition_failed(request) 

153 

154 ifnonematch = request.if_none_match 

155 if ifnonematch is not None and self._strong_etag_match(etag_value, ifnonematch): 

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

157 

158 modsince = request.if_modified_since 

159 if ( 

160 modsince is not None 

161 and ifnonematch is None 

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

163 ): 

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

165 

166 ct = None 

167 if hdrs.CONTENT_TYPE not in self.headers: 

168 ct, encoding = mimetypes.guess_type(str(filepath)) 

169 if not ct: 

170 ct = "application/octet-stream" 

171 else: 

172 encoding = "gzip" if gzip else None 

173 

174 status = self._status 

175 file_size = st.st_size 

176 count = file_size 

177 

178 start = None 

179 

180 ifrange = request.if_range 

181 if ifrange is None or st.st_mtime <= ifrange.timestamp(): 

182 # If-Range header check: 

183 # condition = cached date >= last modification date 

184 # return 206 if True else 200. 

185 # if False: 

186 # Range header would not be processed, return 200 

187 # if True but Range header missing 

188 # return 200 

189 try: 

190 rng = request.http_range 

191 start = rng.start 

192 end = rng.stop 

193 except ValueError: 

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

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

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

197 # with an unsatisfied-range value. 

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

199 # length of the selected representation. 

200 # 

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

202 # send a Content-Range header with HTTP 416 

203 self.headers[hdrs.CONTENT_RANGE] = f"bytes */{file_size}" 

204 self.set_status(HTTPRequestRangeNotSatisfiable.status_code) 

205 return await super().prepare(request) 

206 

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

208 # notation into file pointer offset and count 

209 if start is not None or end is not None: 

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

211 start += file_size 

212 if start < 0: 

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

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

215 start = 0 

216 count = file_size - start 

217 else: 

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

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

220 # the current length of the representation data, 

221 # the byte range is interpreted as the remainder 

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

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

224 # the current length of the selected representation). 

225 count = ( 

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

227 ) 

228 

229 if start >= file_size: 

230 # HTTP 416 should be returned in this case. 

231 # 

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

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

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

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

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

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

238 # byte-range-set is unsatisfiable. 

239 self.headers[hdrs.CONTENT_RANGE] = f"bytes */{file_size}" 

240 self.set_status(HTTPRequestRangeNotSatisfiable.status_code) 

241 return await super().prepare(request) 

242 

243 status = HTTPPartialContent.status_code 

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

245 # return a HTTP 206 for a Range request. 

246 self.set_status(status) 

247 

248 if ct: 

249 self.content_type = ct 

250 if encoding: 

251 self.headers[hdrs.CONTENT_ENCODING] = encoding 

252 if gzip: 

253 self.headers[hdrs.VARY] = hdrs.ACCEPT_ENCODING 

254 

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

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

257 self.content_length = count 

258 

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

260 

261 real_start = cast(int, start) 

262 

263 if status == HTTPPartialContent.status_code: 

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

265 real_start, real_start + count - 1, file_size 

266 ) 

267 

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

269 if count == 0 or request.method == hdrs.METH_HEAD or self.status in [204, 304]: 

270 return await super().prepare(request) 

271 

272 fobj = await loop.run_in_executor(None, filepath.open, "rb") 

273 if start: # be aware that start could be None or int=0 here. 

274 offset = start 

275 else: 

276 offset = 0 

277 

278 try: 

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

280 finally: 

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