Coverage for an_website/utils/options.py: 94.898%
98 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-07 13:44 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-07 13:44 +0000
1# This program is free software: you can redistribute it and/or modify
2# it under the terms of the GNU Affero General Public License as
3# published by the Free Software Foundation, either version 3 of the
4# License, or (at your option) any later version.
5#
6# This program is distributed in the hope that it will be useful,
7# but WITHOUT ANY WARRANTY; without even the implied warranty of
8# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
9# GNU Affero General Public License for more details.
10#
11# You should have received a copy of the GNU Affero General Public License
12# along with this program. If not, see <https://www.gnu.org/licenses/>.
14"""Options in this module are configurable when accessing the website."""
16from __future__ import annotations
18import dataclasses
19import typing
20from abc import ABC
21from collections.abc import Callable, Iterable
22from functools import partial
23from typing import Final, Generic, Literal, TypeVar, overload
25from tornado.web import RequestHandler
27from . import base_request_handler as brh # pylint: disable=cyclic-import
28from .themes import THEMES
29from .utils import (
30 BumpscosityValue,
31 OpenMojiValue,
32 bool_to_str,
33 parse_bumpscosity,
34 parse_openmoji_arg,
35 str_to_bool,
36)
38T = TypeVar("T")
39U = TypeVar("U")
41type ColourScheme = Literal["light", "dark", "system", "random"]
42COLOUR_SCHEMES: Final[tuple[ColourScheme, ...]] = typing.get_args(
43 ColourScheme.__value__ # pylint: disable=no-member
44)
47@dataclasses.dataclass(frozen=True)
48class Option(ABC, Generic[T]):
49 """An option that can be configured when accessing the website."""
51 name: str
52 parse_from_string: Callable[[str, T], T]
53 get_default_value: Callable[[brh.BaseRequestHandler], T]
54 is_valid: Callable[[T], bool] = lambda _: True
55 normalize_string: Callable[[str], str] = lambda value: value
56 value_to_string: Callable[[T], str] = str
57 httponly: bool = True
59 @overload
60 def __get__( # noqa: D105
61 self, obj: None, _: type[Options] | None = None, / # noqa: W504
62 ) -> Option[T]: ...
64 @overload
65 def __get__(self, obj: Options, _: type[Options] | None = None, /) -> T:
66 """Get the value for this option."""
68 def __get__(
69 self, # comment to make Flake8 happy
70 obj: Options | None,
71 _: type[Options] | None = None,
72 /,
73 ) -> T | Option[T]:
74 """Get the value for this option."""
75 if obj is None:
76 return self
77 return self.get_value(obj.request_handler)
79 def __set__(self, obj: brh.BaseRequestHandler, value: object) -> None:
80 """Make this read-only."""
81 raise AttributeError()
83 def _parse(
84 self,
85 *,
86 body_argument: str | None,
87 query_argument: str | None,
88 cookie: str | None,
89 default: T,
90 ) -> T:
91 """Parse the value from a string."""
92 for val in (body_argument, query_argument, cookie):
93 if not val:
94 continue
95 parsed = self.parse_from_string(self.normalize_string(val), default)
96 # is True to catch the case where is_valid returns NotImplemented
97 if self.is_valid(parsed) is True:
98 return parsed
99 return default
101 def get_form_appendix(self, request_handler: brh.BaseRequestHandler) -> str:
102 """Return the form appendix for this option."""
103 if not self.option_in_arguments(request_handler):
104 return ""
105 return (
106 f"<input class='hidden' name={self.name!r} "
107 f"value={self.value_to_string(self.get_value(request_handler))!r}>"
108 )
110 def get_value(
111 self,
112 request_handler: brh.BaseRequestHandler,
113 *,
114 include_body_argument: bool = True,
115 include_query_argument: bool = True,
116 include_cookie: bool = True,
117 ) -> T:
118 """Get the value for this option."""
119 return self._parse(
120 body_argument=(
121 request_handler.get_body_argument(self.name, None)
122 if include_body_argument
123 else None
124 ),
125 query_argument=(
126 request_handler.get_query_argument(self.name, None)
127 if include_query_argument
128 else None
129 ),
130 cookie=(
131 request_handler.get_cookie(self.name, None)
132 if include_cookie
133 else None
134 ),
135 default=self.get_default_value(request_handler),
136 )
138 def option_in_arguments(
139 self, request_handler: brh.BaseRequestHandler
140 ) -> bool:
141 """Return whether the option is taken from the arguments."""
142 return self.get_value(
143 request_handler, include_cookie=False
144 ) != self.get_value(request_handler)
147def parse_int(value: str, default: int) -> int:
148 """Parse the value from a string."""
149 try:
150 return int(value, base=0)
151 except ValueError:
152 return default
155def parse_string(value: str, _: str) -> str:
156 """Parse the value from a string."""
157 return value
160StringOption = partial(Option[str], parse_from_string=parse_string)
161BoolOption = partial(
162 Option[bool], parse_from_string=str_to_bool, value_to_string=bool_to_str
163)
164IntOption = partial(Option[int], parse_from_string=parse_int)
167def false(_: RequestHandler) -> Literal[False]:
168 """Return False."""
169 return False
172def true(_: RequestHandler) -> Literal[True]:
173 """Return True."""
174 return True
177def is_cautious_user(handler: RequestHandler) -> bool:
178 """Return if a user is cautious."""
179 return handler.request.host_name.endswith((".onion", ".i2p"))
182class Options:
183 """Options for the website."""
185 __slots__ = ("__request_handler",)
187 theme: Option[str] = StringOption(
188 name="theme",
189 is_valid=THEMES.__contains__,
190 get_default_value=lambda _: (
191 "default"
192 # pylint: disable-next=misplaced-comparison-constant
193 if _.now.month != 4 or 2 <= _.now.day
194 else "fun"
195 ),
196 normalize_string=lambda s: s.replace("-", "_").lower(),
197 )
198 scheme: Option[ColourScheme] = Option(
199 name="scheme",
200 is_valid=COLOUR_SCHEMES.__contains__,
201 get_default_value=lambda _: "system",
202 normalize_string=str.lower,
203 parse_from_string=lambda val, _: typing.cast(ColourScheme, val),
204 httponly=False,
205 )
206 compat: Option[bool] = BoolOption(name="compat", get_default_value=false)
207 dynload: Option[bool] = BoolOption(name="dynload", get_default_value=false)
208 effects: Option[bool] = BoolOption(name="effects", get_default_value=true)
209 openmoji: Option[OpenMojiValue] = Option(
210 name="openmoji",
211 parse_from_string=parse_openmoji_arg,
212 get_default_value=false,
213 )
214 no_3rd_party: Option[bool] = BoolOption(
215 name="no_3rd_party",
216 get_default_value=is_cautious_user,
217 )
218 ask_before_leaving: Option[bool] = BoolOption(
219 name="ask_before_leaving",
220 get_default_value=false,
221 )
222 bumpscosity: Option[BumpscosityValue] = Option(
223 name="bumpscosity",
224 get_default_value=lambda _: parse_bumpscosity(None),
225 parse_from_string=lambda v, u: parse_bumpscosity(v),
226 )
228 def __init__(self, request_handler: brh.BaseRequestHandler) -> None:
229 """Initialize the options."""
230 self.__request_handler = request_handler
232 def as_dict(
233 self,
234 *,
235 include_body_argument: bool = True,
236 include_query_argument: bool = True,
237 include_cookie: bool = True,
238 ) -> dict[str, object]:
239 """Get all the options in a dictionary."""
240 return {
241 option.name: option.get_value(
242 self.request_handler,
243 include_body_argument=include_body_argument,
244 include_query_argument=include_query_argument,
245 include_cookie=include_cookie,
246 )
247 for option in self.iter_options()
248 }
250 def as_dict_with_str_values(
251 self,
252 *,
253 include_body_argument: bool = True,
254 include_query_argument: bool = True,
255 include_cookie: bool = True,
256 ) -> dict[str, str]:
257 """Get all the options in a dictionary."""
258 return {
259 option.name: option.value_to_string(
260 option.get_value(
261 self.request_handler,
262 include_body_argument=include_body_argument,
263 include_query_argument=include_query_argument,
264 include_cookie=include_cookie,
265 )
266 )
267 for option in self.iter_options()
268 }
270 def get_form_appendix(self) -> str:
271 """Get HTML to add to forms to keep important query args."""
272 return "".join(
273 option.get_form_appendix(self.request_handler)
274 for option in self.iter_options()
275 )
277 def iter_option_names(self) -> Iterable[str]:
278 """Get the names of all options."""
279 for option in self.iter_options():
280 yield option.name
282 def iter_options(self) -> Iterable[Option[object]]:
283 """Get all the options."""
284 for name in dir(self):
285 if name.startswith("_"):
286 continue
287 value = getattr(self.__class__, name)
288 if isinstance(value, Option):
289 yield value
291 @property
292 def request_handler(self) -> brh.BaseRequestHandler:
293 """Return the request handler."""
294 return self.__request_handler