Coverage for an_website/utils/static_file_from_traversable.py: 94.118%

153 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-01 05:07 +0000

1# This file is based on the StaticFileHandler from Tornado 

2# Source: https://github.com/tornadoweb/tornado/blob/b3f2a4bb6fb55f6b1b1e890cdd6332665cfe4a75/tornado/web.py # noqa: B950 # pylint: disable=line-too-long 

3# Licensed under the Apache License, Version 2.0 (the "License"); you may 

4# not use this file except in compliance with the License. You may obtain 

5# a copy of the License at 

6# 

7# http://www.apache.org/licenses/LICENSE-2.0 

8# 

9# Unless required by applicable law or agreed to in writing, software 

10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 

11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 

12# License for the specific language governing permissions and limitations 

13# under the License. 

14 

15"""A static file handler for the Traversable abc.""" 

16 

17import asyncio 

18import contextlib 

19import logging 

20import sys 

21from collections.abc import ( 

22 AsyncIterable, 

23 Awaitable, 

24 Iterable, 

25 Mapping, 

26 Sequence, 

27) 

28from importlib.resources.abc import Traversable 

29from types import MappingProxyType 

30from typing import Any, Final, Literal, override 

31from urllib.parse import urlsplit, urlunsplit 

32 

33from tornado import httputil, iostream 

34from tornado.web import GZipContentEncoding, HTTPError 

35from typed_stream import Stream 

36 

37from an_website.utils.utils import size_of_file 

38 

39from .base_request_handler import _RequestHandler 

40from .static_file_handling import content_type_from_path 

41 

42type Encoding = Literal["gz", "zst"] 

43 

44LOGGER: Final = logging.getLogger(__name__) 

45 

46ENCODINGS: Final[Sequence[tuple[str, Encoding]]] = ( 

47 # better first 

48 ("zstd", "zst"), 

49 ("gzip", "gz"), 

50) 

51REVERSE_ENCODINGS_MAP: Mapping[Encoding, str] = { 

52 value: key for key, value in ENCODINGS 

53} 

54 

55 

56class TraversableStaticFileHandler(_RequestHandler): 

57 """A static file handler for the Traversable abc.""" 

58 

59 root: Traversable 

60 file_hashes: Mapping[str, str] = {} 

61 headers: Iterable[tuple[str, str]] = () 

62 

63 @override 

64 def compute_etag(self) -> None | str: 

65 """Return a pre-computed ETag.""" 

66 return self.file_hashes.get(self.request.path) 

67 

68 async def get(self, path: str, *, head: bool = False) -> None: # noqa: C901 

69 # pylint: disable=too-complex, too-many-branches, too-many-statements 

70 """Handle GET requests for files in the static file directory.""" 

71 if self.request.path.endswith("/"): 

72 self.replace_path_with_redirect(self.request.path.rstrip("/")) 

73 return 

74 

75 if path.startswith("/") or ".." in path.split("/") or "//" in path: 

76 raise HTTPError(404) 

77 

78 absolute_path, encoding = self.get_absolute_path_encoded(path) 

79 

80 if not absolute_path.is_file(): 

81 if self.get_absolute_path(path.lower()).is_file(): 

82 if self.request.path.endswith(path): 

83 self.replace_path_with_redirect( 

84 self.request.path.removesuffix(path) + path.lower() 

85 ) 

86 return 

87 LOGGER.error( 

88 "Failed to fix casing of %s", self.request.full_url() 

89 ) 

90 raise HTTPError(404) 

91 

92 self.set_header("Accept-Ranges", "bytes") 

93 

94 if encoding: 

95 self.set_header("Content-Encoding", REVERSE_ENCODINGS_MAP[encoding]) 

96 if content_type := self.get_content_type( 

97 path, self.get_absolute_path(path) 

98 ): 

99 self.set_header("Content-Type", content_type) 

100 del path 

101 

102 request_range = None 

103 range_header = self.request.headers.get("Range") 

104 if range_header: 

105 # As per RFC 2616 14.16, if an invalid Range header is specified, 

106 # the request will be treated as if the header didn't exist. 

107 # pylint: disable-next=protected-access 

108 request_range = httputil._parse_request_range(range_header) 

109 

110 size: int = size_of_file(absolute_path) 

111 

112 if request_range: 

113 start, end = request_range 

114 if start is not None and start < 0: 

115 start += size 

116 start = max(start, 0) 

117 if ( 

118 start is not None 

119 and (start >= size or (end is not None and start >= end)) 

120 ) or end == 0: # pylint: disable=use-implicit-booleaness-not-comparison-to-zero # noqa: B950 

121 # As per RFC 2616 14.35.1, a range is not satisfiable only: if 

122 # the first requested byte is equal to or greater than the 

123 # content, or when a suffix with length 0 is specified. 

124 # https://tools.ietf.org/html/rfc7233#section-2.1 

125 # A byte-range-spec is invalid if the last-byte-pos value is present 

126 # and less than the first-byte-pos. 

127 self.set_status(416) # Range Not Satisfiable 

128 self.set_header("Content-Type", "text/plain") 

129 self.set_header("Content-Range", f"bytes */{size}") 

130 return 

131 if end is not None and end > size: 

132 # Clients sometimes blindly use a large range to limit their 

133 # download size; cap the endpoint at the actual file size. 

134 end = size 

135 # Note: only return HTTP 206 if less than the entire range has been 

136 # requested. Not only is this semantically correct, but Chrome 

137 # refuses to play audio if it gets an HTTP 206 in response to 

138 # ``Range: bytes=0-``. 

139 if size != (end or size) - (start or 0): 

140 self.set_status(206) # Partial Content 

141 self.set_header( 

142 "Content-Range", 

143 # pylint: disable-next=protected-access 

144 httputil._get_content_range(start, end, size), 

145 ) 

146 else: 

147 start = end = None 

148 

149 content_length = len(range(size)[start:end]) 

150 self.set_header("Content-Length", content_length) 

151 

152 if head: 

153 assert self.request.method == "HEAD" 

154 await self.finish() 

155 return 

156 

157 async for chunk in self.get_content( 

158 absolute_path, start=start, end=end 

159 ): 

160 self.write(chunk) 

161 del chunk # delete chunk asap 

162 try: 

163 await self.flush() 

164 except iostream.StreamClosedError: 

165 return 

166 except httputil.HTTPOutputError: 

167 LOGGER.exception( 

168 "Connection %s; %s", 

169 self.request.connection, 

170 getattr(self.request.connection, "__dict__", None), 

171 ) 

172 raise 

173 

174 with contextlib.suppress(iostream.StreamClosedError): 

175 await self.finish() 

176 

177 def get_absolute_path(self, path: str) -> Traversable: 

178 """Get the absolute path of a file.""" 

179 return self.root / path 

180 

181 def get_absolute_path_encoded( 

182 self, path: str 

183 ) -> tuple[Traversable, Encoding | None]: 

184 """Get the absolute path and the encoding.""" 

185 for transform in self._transforms: 

186 if isinstance(transform, GZipContentEncoding): 

187 # pylint: disable=protected-access 

188 transform._gzipping = False 

189 

190 accepted_encodings: frozenset[str] = ( 

191 Stream(self.request.headers.get_list("Accept-Encoding")) 

192 .flat_map(str.split, ",") 

193 .map(lambda string: string.split(";")[0]) # ignore quality specs 

194 .map(str.strip) 

195 .collect(frozenset) 

196 ) 

197 

198 absolute_path = self.get_absolute_path(path) 

199 encoding: Encoding | None = None 

200 

201 for key, encoding in ENCODINGS: 

202 if key in accepted_encodings: 

203 compressed_path = self.get_absolute_path(f"{path}.{encoding}") 

204 if compressed_path.is_file(): 

205 absolute_path = compressed_path 

206 break 

207 encoding = None # pylint: disable=redefined-loop-name 

208 

209 return absolute_path, encoding 

210 

211 @classmethod 

212 async def get_content( 

213 cls, 

214 abspath: Traversable, 

215 start: int | None = None, 

216 end: int | None = None, 

217 ) -> AsyncIterable[bytes]: 

218 """Read the content of a file in chunks.""" 

219 with abspath.open("rb") as file: 

220 if start is not None: 

221 file.seek(start) 

222 remaining: int | None = ( 

223 (end - (start or 0)) if end is not None else None 

224 ) 

225 

226 while True: # pylint: disable=while-used 

227 chunk_size = 64 * 1024 

228 if remaining is not None and remaining < chunk_size: 

229 chunk_size = remaining 

230 chunk = file.read(chunk_size) 

231 if chunk: 

232 if remaining is not None: 

233 remaining -= len(chunk) 

234 yield chunk 

235 else: 

236 assert not remaining 

237 return 

238 del chunk 

239 # sleep to make sure we do not block the event loop for too long 

240 # if the connection is too fast `self.flush()` may not suspend 

241 if remaining: 

242 await asyncio.sleep(0) 

243 

244 @classmethod 

245 def get_content_type( 

246 cls, path: str, absolute_path: Traversable 

247 ) -> str | None: 

248 """Get the content-type of a file.""" 

249 return content_type_from_path(path, absolute_path) 

250 

251 def head(self, path: str) -> Awaitable[None]: 

252 """Handle HEAD requests for files in the static file directory.""" 

253 return self.get(path, head=True) 

254 

255 def initialize( 

256 self, 

257 root: Traversable, 

258 hashes: Mapping[str, str] = MappingProxyType({}), 

259 headers: Iterable[tuple[str, str]] = (), 

260 ) -> None: 

261 """Initialize this handler with a root directory and file hashes.""" 

262 self.root = root 

263 self.file_hashes = hashes 

264 self.headers = headers 

265 for name, value in headers: 

266 self.set_header(name, value) 

267 if not sys.flags.dev_mode: 

268 self.set_etag_header() 

269 

270 def replace_path_with_redirect( 

271 self, new_path: str, *, status: int = 307 

272 ) -> None: 

273 """Redirect to the replaced path.""" 

274 scheme, netloc, _, query, _ = urlsplit(self.request.full_url()) 

275 self.redirect( 

276 urlunsplit( 

277 ( 

278 scheme, 

279 netloc, 

280 new_path, 

281 query, 

282 "", 

283 ) 

284 ), 

285 status=status, 

286 ) 

287 

288 @override 

289 def set_default_headers(self) -> None: 

290 """Set the default headers for this handler.""" 

291 super().set_default_headers() 

292 for name, value in self.headers: 

293 self.set_header(name, value) 

294 

295 if not sys.flags.dev_mode: 

296 if "v" in self.request.arguments: 

297 self.set_header( # never changes 

298 "Cache-Control", 

299 f"public,immutable,max-age={86400 * 365 * 10}", 

300 ) 

301 else: 

302 self.set_etag_header() 

303 

304 @override 

305 def write_error(self, status_code: int, **kwargs: Any) -> None: 

306 """Write an error response.""" 

307 self.set_header("Content-Type", "text/plain;charset=utf-8") 

308 self.write(str(status_code)) 

309 self.write(" ") 

310 self.write(self._reason) 

311 _ = self.finish("\n")