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

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 

16from __future__ import annotations 

17 

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 

24 

25from tornado.web import RequestHandler 

26 

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) 

37 

38T = TypeVar("T") 

39U = TypeVar("U") 

40 

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

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

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

44) 

45 

46 

47@dataclasses.dataclass(frozen=True) 

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

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

50 

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 

58 

59 @overload 

60 def __get__( # noqa: D105 

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

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

63 

64 @overload 

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

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

67 

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) 

78 

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

80 """Make this read-only.""" 

81 raise AttributeError() 

82 

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 

100 

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 ) 

109 

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 ) 

137 

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) 

145 

146 

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 

153 

154 

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

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

157 return value 

158 

159 

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) 

165 

166 

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

168 """Return False.""" 

169 return False 

170 

171 

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

173 """Return True.""" 

174 return True 

175 

176 

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

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

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

180 

181 

182class Options: 

183 """Options for the website.""" 

184 

185 __slots__ = ("__request_handler",) 

186 

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 ) 

227 

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

229 """Initialize the options.""" 

230 self.__request_handler = request_handler 

231 

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 } 

249 

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 } 

269 

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 ) 

276 

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 

281 

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 

290 

291 @property 

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

293 """Return the request handler.""" 

294 return self.__request_handler