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
« 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.
15"""A static file handler for the Traversable abc."""
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
33from tornado import httputil, iostream
34from tornado.web import GZipContentEncoding, HTTPError
35from typed_stream import Stream
37from an_website.utils.utils import size_of_file
39from .base_request_handler import _RequestHandler
40from .static_file_handling import content_type_from_path
42type Encoding = Literal["gz", "zst"]
44LOGGER: Final = logging.getLogger(__name__)
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}
56class TraversableStaticFileHandler(_RequestHandler):
57 """A static file handler for the Traversable abc."""
59 root: Traversable
60 file_hashes: Mapping[str, str] = {}
61 headers: Iterable[tuple[str, str]] = ()
63 @override
64 def compute_etag(self) -> None | str:
65 """Return a pre-computed ETag."""
66 return self.file_hashes.get(self.request.path)
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
75 if path.startswith("/") or ".." in path.split("/") or "//" in path:
76 raise HTTPError(404)
78 absolute_path, encoding = self.get_absolute_path_encoded(path)
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)
92 self.set_header("Accept-Ranges", "bytes")
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
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)
110 size: int = size_of_file(absolute_path)
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
149 content_length = len(range(size)[start:end])
150 self.set_header("Content-Length", content_length)
152 if head:
153 assert self.request.method == "HEAD"
154 await self.finish()
155 return
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
174 with contextlib.suppress(iostream.StreamClosedError):
175 await self.finish()
177 def get_absolute_path(self, path: str) -> Traversable:
178 """Get the absolute path of a file."""
179 return self.root / path
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
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 )
198 absolute_path = self.get_absolute_path(path)
199 encoding: Encoding | None = None
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
209 return absolute_path, encoding
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 )
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)
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)
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)
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()
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 )
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)
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()
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")