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

142 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-07 06:52 +0000

1import asyncio 

2import mimetypes 

3import os 

4import pathlib 

5from typing import ( # noqa 

6 IO, 

7 TYPE_CHECKING, 

8 Any, 

9 Awaitable, 

10 Callable, 

11 Iterator, 

12 List, 

13 Optional, 

14 Tuple, 

15 Union, 

16 cast, 

17) 

18 

19from typing_extensions import Final 

20 

21from . import hdrs 

22from .abc import AbstractStreamWriter 

23from .helpers import ETAG_ANY, ETag 

24from .typedefs import LooseHeaders, PathLike 

25from .web_exceptions import ( 

26 HTTPNotModified, 

27 HTTPPartialContent, 

28 HTTPPreconditionFailed, 

29 HTTPRequestRangeNotSatisfiable, 

30) 

31from .web_response import StreamResponse 

32 

33__all__ = ("FileResponse",) 

34 

35if TYPE_CHECKING: # pragma: no cover 

36 from .web_request import BaseRequest 

37 

38 

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

40 

41 

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

43 

44 

45class FileResponse(StreamResponse): 

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

47 

48 def __init__( 

49 self, 

50 path: PathLike, 

51 chunk_size: int = 256 * 1024, 

52 status: int = 200, 

53 reason: Optional[str] = None, 

54 headers: Optional[LooseHeaders] = None, 

55 ) -> None: 

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

57 

58 self._path = pathlib.Path(path) 

59 self._chunk_size = chunk_size 

60 

61 async def _sendfile_fallback( 

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

63 ) -> AbstractStreamWriter: 

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

65 # controlled by the constructor's chunk_size argument. 

66 

67 chunk_size = self._chunk_size 

68 loop = asyncio.get_event_loop() 

69 

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

71 

72 chunk = await loop.run_in_executor(None, fobj.read, chunk_size) 

73 while chunk: 

74 await writer.write(chunk) 

75 count = count - chunk_size 

76 if count <= 0: 

77 break 

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

79 

80 await writer.drain() 

81 return writer 

82 

83 async def _sendfile( 

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

85 ) -> AbstractStreamWriter: 

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

87 assert writer is not None 

88 

89 if NOSENDFILE or self.compression: 

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

91 

92 loop = request._loop 

93 transport = request.transport 

94 assert transport is not None 

95 

96 try: 

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

98 except NotImplementedError: 

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

100 

101 await super().write_eof() 

102 return writer 

103 

104 @staticmethod 

105 def _strong_etag_match(etag_value: str, etags: Tuple[ETag, ...]) -> bool: 

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

107 return True 

108 return any(etag.value == etag_value for etag in etags if not etag.is_weak) 

109 

110 async def _not_modified( 

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

112 ) -> Optional[AbstractStreamWriter]: 

113 self.set_status(HTTPNotModified.status_code) 

114 self._length_check = False 

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

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

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

118 # should always have empty response body 

119 return await super().prepare(request) 

120 

121 async def _precondition_failed( 

122 self, request: "BaseRequest" 

123 ) -> Optional[AbstractStreamWriter]: 

124 self.set_status(HTTPPreconditionFailed.status_code) 

125 self.content_length = 0 

126 return await super().prepare(request) 

127 

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

129 filepath = self._path 

130 

131 gzip = False 

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

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

134 

135 if gzip_path.is_file(): 

136 filepath = gzip_path 

137 gzip = True 

138 

139 loop = asyncio.get_event_loop() 

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

141 

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

143 last_modified = st.st_mtime 

144 

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

146 ifmatch = request.if_match 

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

148 return await self._precondition_failed(request) 

149 

150 unmodsince = request.if_unmodified_since 

151 if ( 

152 unmodsince is not None 

153 and ifmatch is None 

154 and st.st_mtime > unmodsince.timestamp() 

155 ): 

156 return await self._precondition_failed(request) 

157 

158 ifnonematch = request.if_none_match 

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

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

161 

162 modsince = request.if_modified_since 

163 if ( 

164 modsince is not None 

165 and ifnonematch is None 

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

167 ): 

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

169 

170 if hdrs.CONTENT_TYPE not in self.headers: 

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

172 if not ct: 

173 ct = "application/octet-stream" 

174 should_set_ct = True 

175 else: 

176 encoding = "gzip" if gzip else None 

177 should_set_ct = False 

178 

179 status = self._status 

180 file_size = st.st_size 

181 count = file_size 

182 

183 start = None 

184 

185 ifrange = request.if_range 

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

187 # If-Range header check: 

188 # condition = cached date >= last modification date 

189 # return 206 if True else 200. 

190 # if False: 

191 # Range header would not be processed, return 200 

192 # if True but Range header missing 

193 # return 200 

194 try: 

195 rng = request.http_range 

196 start = rng.start 

197 end = rng.stop 

198 except ValueError: 

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

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

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

202 # with an unsatisfied-range value. 

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

204 # length of the selected representation. 

205 # 

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

207 # send a Content-Range header with HTTP 416 

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

209 self.set_status(HTTPRequestRangeNotSatisfiable.status_code) 

210 return await super().prepare(request) 

211 

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

213 # notation into file pointer offset and count 

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

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

216 start += file_size 

217 if start < 0: 

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

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

220 start = 0 

221 count = file_size - start 

222 else: 

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

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

225 # the current length of the representation data, 

226 # the byte range is interpreted as the remainder 

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

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

229 # the current length of the selected representation). 

230 count = ( 

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

232 ) 

233 

234 if start >= file_size: 

235 # HTTP 416 should be returned in this case. 

236 # 

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

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

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

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

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

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

243 # byte-range-set is unsatisfiable. 

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

245 self.set_status(HTTPRequestRangeNotSatisfiable.status_code) 

246 return await super().prepare(request) 

247 

248 status = HTTPPartialContent.status_code 

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

250 # return a HTTP 206 for a Range request. 

251 self.set_status(status) 

252 

253 if should_set_ct: 

254 self.content_type = ct # type: ignore[assignment] 

255 if encoding: 

256 self.headers[hdrs.CONTENT_ENCODING] = encoding 

257 if gzip: 

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

259 

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

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

262 self.content_length = count 

263 

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

265 

266 real_start = cast(int, start) 

267 

268 if status == HTTPPartialContent.status_code: 

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

270 real_start, real_start + count - 1, file_size 

271 ) 

272 

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

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

275 return await super().prepare(request) 

276 

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

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

279 offset = start 

280 else: 

281 offset = 0 

282 

283 try: 

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

285 finally: 

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