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

97 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-01 14:47 +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 

58 @overload 

59 def __get__( # noqa: D105 

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

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

62 

63 @overload 

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

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

66 

67 def __get__( 

68 self, # comment to make Flake8 happy 

69 obj: Options | None, 

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

71 /, 

72 ) -> T | Option[T]: 

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

74 if obj is None: 

75 return self 

76 return self.get_value(obj.request_handler) 

77 

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

79 """Make this read-only.""" 

80 raise AttributeError() 

81 

82 def _parse( 

83 self, 

84 *, 

85 body_argument: str | None, 

86 query_argument: str | None, 

87 cookie: str | None, 

88 default: T, 

89 ) -> T: 

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

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

92 if not val: 

93 continue 

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

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

96 if self.is_valid(parsed) is True: 

97 return parsed 

98 return default 

99 

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

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

102 if not self.option_in_arguments(request_handler): 

103 return "" 

104 return ( 

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

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

107 ) 

108 

109 def get_value( 

110 self, 

111 request_handler: brh.BaseRequestHandler, 

112 *, 

113 include_body_argument: bool = True, 

114 include_query_argument: bool = True, 

115 include_cookie: bool = True, 

116 ) -> T: 

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

118 return self._parse( 

119 body_argument=( 

120 request_handler.get_body_argument(self.name, None) 

121 if include_body_argument 

122 else None 

123 ), 

124 query_argument=( 

125 request_handler.get_query_argument(self.name, None) 

126 if include_query_argument 

127 else None 

128 ), 

129 cookie=( 

130 request_handler.get_cookie(self.name, None) 

131 if include_cookie 

132 else None 

133 ), 

134 default=self.get_default_value(request_handler), 

135 ) 

136 

137 def option_in_arguments( 

138 self, request_handler: brh.BaseRequestHandler 

139 ) -> bool: 

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

141 return self.get_value( 

142 request_handler, include_cookie=False 

143 ) != self.get_value(request_handler) 

144 

145 

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

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

148 try: 

149 return int(value, base=0) 

150 except ValueError: 

151 return default 

152 

153 

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

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

156 return value 

157 

158 

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

160BoolOption = partial( 

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

162) 

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

164 

165 

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

167 """Return False.""" 

168 return False 

169 

170 

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

172 """Return True.""" 

173 return True 

174 

175 

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

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

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

179 

180 

181class Options: 

182 """Options for the website.""" 

183 

184 __slots__ = ("__request_handler",) 

185 

186 theme: Option[str] = StringOption( 

187 name="theme", 

188 is_valid=THEMES.__contains__, 

189 get_default_value=lambda _: ( 

190 "default" 

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

192 if _.now.month != 4 or 2 <= _.now.day 

193 else "fun" 

194 ), 

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

196 ) 

197 scheme: Option[ColourScheme] = Option( 

198 name="scheme", 

199 is_valid=COLOUR_SCHEMES.__contains__, 

200 get_default_value=lambda _: "system", 

201 normalize_string=str.lower, 

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

203 ) 

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

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

206 effects: Option[bool] = BoolOption(name="effects", get_default_value=true) 

207 openmoji: Option[OpenMojiValue] = Option( 

208 name="openmoji", 

209 parse_from_string=parse_openmoji_arg, 

210 get_default_value=false, 

211 ) 

212 no_3rd_party: Option[bool] = BoolOption( 

213 name="no_3rd_party", 

214 get_default_value=is_cautious_user, 

215 ) 

216 ask_before_leaving: Option[bool] = BoolOption( 

217 name="ask_before_leaving", 

218 get_default_value=false, 

219 ) 

220 bumpscosity: Option[BumpscosityValue] = Option( 

221 name="bumpscosity", 

222 get_default_value=lambda _: parse_bumpscosity(None), 

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

224 ) 

225 

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

227 """Initialize the options.""" 

228 self.__request_handler = request_handler 

229 

230 def as_dict( 

231 self, 

232 *, 

233 include_body_argument: bool = True, 

234 include_query_argument: bool = True, 

235 include_cookie: bool = True, 

236 ) -> dict[str, object]: 

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

238 return { 

239 option.name: option.get_value( 

240 self.request_handler, 

241 include_body_argument=include_body_argument, 

242 include_query_argument=include_query_argument, 

243 include_cookie=include_cookie, 

244 ) 

245 for option in self.iter_options() 

246 } 

247 

248 def as_dict_with_str_values( 

249 self, 

250 *, 

251 include_body_argument: bool = True, 

252 include_query_argument: bool = True, 

253 include_cookie: bool = True, 

254 ) -> dict[str, str]: 

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

256 return { 

257 option.name: option.value_to_string( 

258 option.get_value( 

259 self.request_handler, 

260 include_body_argument=include_body_argument, 

261 include_query_argument=include_query_argument, 

262 include_cookie=include_cookie, 

263 ) 

264 ) 

265 for option in self.iter_options() 

266 } 

267 

268 def get_form_appendix(self) -> str: 

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

270 return "".join( 

271 option.get_form_appendix(self.request_handler) 

272 for option in self.iter_options() 

273 ) 

274 

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

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

277 for option in self.iter_options(): 

278 yield option.name 

279 

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

281 """Get all the options.""" 

282 for name in dir(self): 

283 if name.startswith("_"): 

284 continue 

285 value = getattr(self.__class__, name) 

286 if isinstance(value, Option): 

287 yield value 

288 

289 @property 

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

291 """Return the request handler.""" 

292 return self.__request_handler