Coverage for an_website / utils / options.py: 94.175%

103 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-19 18:33 +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/>. 

13 

14"""Options in this module are configurable when accessing the website.""" 

15 

16import dataclasses 

17import typing 

18from abc import ABC 

19from collections.abc import Callable, Iterable 

20from functools import partial 

21from typing import Final, Generic, Literal, TypeVar, overload 

22 

23from tornado.web import RequestHandler 

24 

25from . import base_request_handler as brh # pylint: disable=cyclic-import 

26from .themes import THEMES 

27from .utils import ( 

28 BumpscosityValue, 

29 OpenMojiValue, 

30 bool_to_str, 

31 parse_bumpscosity, 

32 parse_openmoji_arg, 

33 str_to_bool, 

34) 

35 

36T = TypeVar("T") 

37U = TypeVar("U") 

38 

39type ColourScheme = Literal["light", "dark", "system", "random"] 

40COLOUR_SCHEMES: Final[tuple[ColourScheme, ...]] = typing.get_args( 

41 ColourScheme.__value__ # pylint: disable=no-member 

42) 

43type Trilean = Literal[True, None, False] 

44TRILEAN_VALUES: Final[tuple[Trilean, ...]] = typing.get_args( 

45 Trilean.__value__ # pylint: disable=no-member 

46) 

47 

48 

49@dataclasses.dataclass(frozen=True) 

50class Option(ABC, Generic[T]): 

51 """An option that can be configured when accessing the website.""" 

52 

53 name: str 

54 parse_from_string: Callable[[str, T], T] 

55 get_default_value: Callable[[brh.BaseRequestHandler], T] 

56 is_valid: Callable[[T], bool] = lambda _: True 

57 normalize_string: Callable[[str], str] = lambda value: value 

58 value_to_string: Callable[[T], str] = str 

59 httponly: bool = True 

60 

61 @overload 

62 def __get__( # noqa: D105 

63 self, obj: None, _: type[Options] | None = None, / # noqa: W504 

64 ) -> Option[T]: ... 

65 

66 @overload 

67 def __get__(self, obj: Options, _: type[Options] | None = None, /) -> T: 

68 """Get the value for this option.""" 

69 

70 def __get__( 

71 self, # comment to make Flake8 happy 

72 obj: Options | None, 

73 _: type[Options] | None = None, 

74 /, 

75 ) -> T | Option[T]: 

76 """Get the value for this option.""" 

77 if obj is None: 

78 return self 

79 return self.get_value(obj.request_handler) 

80 

81 def __set__(self, obj: brh.BaseRequestHandler, value: object) -> None: 

82 """Make this read-only.""" 

83 raise AttributeError() 

84 

85 def _parse( 

86 self, 

87 *, 

88 body_argument: str | None, 

89 query_argument: str | None, 

90 cookie: str | None, 

91 default: T, 

92 ) -> T: 

93 """Parse the value from a string.""" 

94 for val in (body_argument, query_argument, cookie): 

95 if not val: 

96 continue 

97 parsed = self.parse_from_string(self.normalize_string(val), default) 

98 # is True to catch the case where is_valid returns NotImplemented 

99 if self.is_valid(parsed) is True: 

100 return parsed 

101 return default 

102 

103 def get_form_appendix(self, request_handler: brh.BaseRequestHandler) -> str: 

104 """Return the form appendix for this option.""" 

105 if not self.option_in_arguments(request_handler): 

106 return "" 

107 return ( 

108 f'<input class="hidden" name={self.name!r} ' 

109 f"value={self.value_to_string(self.get_value(request_handler))!r}>" 

110 ) 

111 

112 def get_value( 

113 self, 

114 request_handler: brh.BaseRequestHandler, 

115 *, 

116 include_body_argument: bool = True, 

117 include_query_argument: bool = True, 

118 include_cookie: bool = True, 

119 ) -> T: 

120 """Get the value for this option.""" 

121 return self._parse( 

122 body_argument=( 

123 request_handler.get_body_argument(self.name, None) 

124 if include_body_argument 

125 else None 

126 ), 

127 query_argument=( 

128 request_handler.get_query_argument(self.name, None) 

129 if include_query_argument 

130 else None 

131 ), 

132 cookie=( 

133 request_handler.get_cookie(self.name, None) 

134 if include_cookie 

135 else None 

136 ), 

137 default=self.get_default_value(request_handler), 

138 ) 

139 

140 def option_in_arguments( 

141 self, request_handler: brh.BaseRequestHandler 

142 ) -> bool: 

143 """Return whether the option is taken from the arguments.""" 

144 return self.get_value( 

145 request_handler, include_cookie=False 

146 ) != self.get_default_value(request_handler) 

147 

148 

149def parse_int(value: str, default: int) -> int: 

150 """Parse the value from a string.""" 

151 try: 

152 return int(value, base=0) 

153 except ValueError: 

154 return default 

155 

156 

157def parse_string(value: str, _: str) -> str: 

158 """Parse the value from a string.""" 

159 return value 

160 

161 

162def parse_trilean(value: str, default: Trilean) -> Trilean: 

163 """Parse the value from a string.""" 

164 return str_to_bool(value, default) if value else None 

165 

166 

167def trilean_to_string(value: Trilean) -> str: 

168 """Convert the value to a string.""" 

169 return "" if value is None else bool_to_str(value) 

170 

171 

172StringOption = partial(Option[str], parse_from_string=parse_string) 

173BoolOption = partial( 

174 Option[bool], parse_from_string=str_to_bool, value_to_string=bool_to_str 

175) 

176IntOption = partial(Option[int], parse_from_string=parse_int) 

177TrileanOption = partial( 

178 Option[Trilean], 

179 is_valid=TRILEAN_VALUES.__contains__, 

180 parse_from_string=parse_trilean, 

181 value_to_string=trilean_to_string, 

182) 

183 

184 

185def false(_: RequestHandler) -> Literal[False]: 

186 """Return False.""" 

187 return False 

188 

189 

190def null(_: RequestHandler) -> None: 

191 """Return None.""" 

192 

193 

194def true(_: RequestHandler) -> Literal[True]: 

195 """Return True.""" 

196 return True 

197 

198 

199def is_cautious_user(handler: RequestHandler) -> bool: 

200 """Return if a user is cautious.""" 

201 return handler.request.host_name.endswith((".onion", ".i2p")) 

202 

203 

204class Options: 

205 """Options for the website.""" 

206 

207 __slots__ = ("_request_handler",) 

208 

209 theme: Option[str] = StringOption( 

210 name="theme", 

211 is_valid=THEMES.__contains__, 

212 get_default_value=lambda handler: ( 

213 "default" 

214 # pylint: disable-next=misplaced-comparison-constant 

215 if handler.now.month != 4 or 2 <= handler.now.day 

216 else "fun" 

217 ), 

218 normalize_string=lambda s: s.replace("-", "_").lower(), 

219 ) 

220 scheme: Option[ColourScheme] = Option( 

221 name="scheme", 

222 is_valid=COLOUR_SCHEMES.__contains__, 

223 get_default_value=lambda _: "system", 

224 normalize_string=str.lower, 

225 parse_from_string=lambda val, _: typing.cast(ColourScheme, val), 

226 httponly=False, 

227 ) 

228 compat: Option[bool] = BoolOption(name="compat", get_default_value=false) 

229 dynload: Option[bool] = BoolOption(name="dynload", get_default_value=false) 

230 stanley: Option[Trilean] = TrileanOption( 

231 name="stanley", get_default_value=null 

232 ) 

233 effects: Option[bool] = BoolOption( 

234 name="effects", 

235 get_default_value=lambda handler: ( 

236 handler.request.headers.get("Sec-CH-Prefers-Reduced-Motion") 

237 != "reduce" 

238 ), 

239 ) 

240 openmoji: Option[OpenMojiValue] = Option( 

241 name="openmoji", 

242 parse_from_string=parse_openmoji_arg, 

243 get_default_value=false, 

244 ) 

245 no_3rd_party: Option[bool] = BoolOption( 

246 name="no_3rd_party", 

247 get_default_value=is_cautious_user, 

248 ) 

249 ask_before_leaving: Option[bool] = BoolOption( 

250 name="ask_before_leaving", 

251 get_default_value=false, 

252 ) 

253 bumpscosity: Option[BumpscosityValue] = Option( 

254 name="bumpscosity", 

255 get_default_value=lambda _: parse_bumpscosity(None), 

256 parse_from_string=lambda v, u: parse_bumpscosity(v), 

257 ) 

258 

259 def __init__(self, request_handler: brh.BaseRequestHandler) -> None: 

260 """Initialize the options.""" 

261 self._request_handler = request_handler 

262 

263 def as_dict( 

264 self, 

265 *, 

266 include_body_argument: bool = True, 

267 include_query_argument: bool = True, 

268 include_cookie: bool = True, 

269 ) -> dict[str, object]: 

270 """Get all the options in a dictionary.""" 

271 return { 

272 option.name: option.get_value( 

273 self.request_handler, 

274 include_body_argument=include_body_argument, 

275 include_query_argument=include_query_argument, 

276 include_cookie=include_cookie, 

277 ) 

278 for option in self.iter_options() 

279 } 

280 

281 def as_dict_with_str_values( 

282 self, 

283 *, 

284 include_body_argument: bool = True, 

285 include_query_argument: bool = True, 

286 include_cookie: bool = True, 

287 ) -> dict[str, str]: 

288 """Get all the options in a dictionary.""" 

289 return { 

290 option.name: option.value_to_string( 

291 option.get_value( 

292 self.request_handler, 

293 include_body_argument=include_body_argument, 

294 include_query_argument=include_query_argument, 

295 include_cookie=include_cookie, 

296 ) 

297 ) 

298 for option in self.iter_options() 

299 } 

300 

301 def get_form_appendix(self) -> str: 

302 """Get HTML to add to forms to keep important query args.""" 

303 return "".join( 

304 option.get_form_appendix(self.request_handler) 

305 for option in self.iter_options() 

306 ) 

307 

308 def iter_option_names(self) -> Iterable[str]: 

309 """Get the names of all options.""" 

310 for option in self.iter_options(): 

311 yield option.name 

312 

313 def iter_options(self) -> Iterable[Option[object]]: 

314 """Get all the options.""" 

315 for name in dir(self): 

316 if name.startswith("_"): 

317 continue 

318 value = getattr(self.__class__, name) 

319 if isinstance(value, Option): 

320 yield value 

321 

322 @property 

323 def request_handler(self) -> brh.BaseRequestHandler: 

324 """Return the request handler.""" 

325 return self._request_handler