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

92 statements  

« prev     ^ index     » next       coverage.py v7.6.4, created at 2024-11-16 19:56 +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 

19from abc import ABC 

20from collections.abc import Callable, Iterable 

21from functools import partial 

22from typing import Generic, Literal, TypeVar, overload 

23 

24from tornado.web import RequestHandler 

25 

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 

39 

40@dataclasses.dataclass(frozen=True) 

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

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

43 

44 name: str 

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

46 get_default_value: Callable[[RequestHandler], T] 

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

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

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

50 

51 @overload 

52 def __get__( # noqa: D105 

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

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

55 

56 @overload 

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

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

59 

60 def __get__( 

61 self, # comment to make Flake8 happy 

62 obj: Options | None, 

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

64 /, 

65 ) -> T | Option[T]: 

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

67 if obj is None: 

68 return self 

69 return self.get_value(obj.request_handler) 

70 

71 def __set__(self, obj: RequestHandler, value: object) -> None: 

72 """Make this read-only.""" 

73 raise AttributeError() 

74 

75 def _parse( 

76 self, 

77 *, 

78 body_argument: str | None, 

79 query_argument: str | None, 

80 cookie: str | None, 

81 default: T, 

82 ) -> T: 

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

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

85 if not val: 

86 continue 

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

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

89 if self.is_valid(parsed) is True: 

90 return parsed 

91 return default 

92 

93 def get_form_appendix(self, request_handler: RequestHandler) -> str: 

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

95 if not self.option_in_arguments(request_handler): 

96 return "" 

97 return ( 

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

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

100 ) 

101 

102 def get_value( 

103 self, 

104 request_handler: RequestHandler, 

105 *, 

106 include_body_argument: bool = True, 

107 include_query_argument: bool = True, 

108 include_cookie: bool = True, 

109 ) -> T: 

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

111 return self._parse( 

112 body_argument=( 

113 request_handler.get_body_argument(self.name, None) 

114 if include_body_argument 

115 else None 

116 ), 

117 query_argument=( 

118 request_handler.get_query_argument(self.name, None) 

119 if include_query_argument 

120 else None 

121 ), 

122 cookie=( 

123 request_handler.get_cookie(self.name, None) 

124 if include_cookie 

125 else None 

126 ), 

127 default=self.get_default_value(request_handler), 

128 ) 

129 

130 def option_in_arguments(self, request_handler: RequestHandler) -> bool: 

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

132 return self.get_value( 

133 request_handler, include_cookie=False 

134 ) != self.get_value(request_handler) 

135 

136 

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

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

139 try: 

140 return int(value, base=0) 

141 except ValueError: 

142 return default 

143 

144 

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

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

147 return value 

148 

149 

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

151BoolOption = partial( 

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

153) 

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

155 

156 

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

158 """Return False.""" 

159 return False 

160 

161 

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

163 """Return True.""" 

164 return True 

165 

166 

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

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

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

170 

171 

172class Options: 

173 """Options for the website.""" 

174 

175 __slots__ = ("__request_handler",) 

176 

177 theme: Option[str] = StringOption( 

178 name="theme", 

179 is_valid=THEMES.__contains__, 

180 get_default_value=lambda _: "default", 

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

182 ) 

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

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

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

186 openmoji: Option[OpenMojiValue] = Option( 

187 name="openmoji", 

188 parse_from_string=parse_openmoji_arg, 

189 get_default_value=false, 

190 ) 

191 no_3rd_party: Option[bool] = BoolOption( 

192 name="no_3rd_party", 

193 get_default_value=is_cautious_user, 

194 ) 

195 ask_before_leaving: Option[bool] = BoolOption( 

196 name="ask_before_leaving", 

197 get_default_value=false, 

198 ) 

199 bumpscosity: Option[BumpscosityValue] = Option( 

200 name="bumpscosity", 

201 get_default_value=lambda _: parse_bumpscosity(None), 

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

203 ) 

204 

205 def __init__(self, request_handler: RequestHandler) -> None: 

206 """Initialize the options.""" 

207 self.__request_handler = request_handler 

208 

209 def as_dict( 

210 self, 

211 *, 

212 include_body_argument: bool = True, 

213 include_query_argument: bool = True, 

214 include_cookie: bool = True, 

215 ) -> dict[str, object]: 

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

217 return { 

218 option.name: option.get_value( 

219 self.request_handler, 

220 include_body_argument=include_body_argument, 

221 include_query_argument=include_query_argument, 

222 include_cookie=include_cookie, 

223 ) 

224 for option in self.iter_options() 

225 } 

226 

227 def as_dict_with_str_values( 

228 self, 

229 *, 

230 include_body_argument: bool = True, 

231 include_query_argument: bool = True, 

232 include_cookie: bool = True, 

233 ) -> dict[str, str]: 

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

235 return { 

236 option.name: option.value_to_string( 

237 option.get_value( 

238 self.request_handler, 

239 include_body_argument=include_body_argument, 

240 include_query_argument=include_query_argument, 

241 include_cookie=include_cookie, 

242 ) 

243 ) 

244 for option in self.iter_options() 

245 } 

246 

247 def get_form_appendix(self) -> str: 

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

249 return "".join( 

250 option.get_form_appendix(self.request_handler) 

251 for option in self.iter_options() 

252 ) 

253 

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

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

256 for option in self.iter_options(): 

257 yield option.name 

258 

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

260 """Get all the options.""" 

261 for name in dir(self): 

262 if name.startswith("_"): 

263 continue 

264 value = getattr(self.__class__, name) 

265 if isinstance(value, Option): 

266 yield value 

267 

268 @property 

269 def request_handler(self) -> RequestHandler: 

270 """Return the request handler.""" 

271 return self.__request_handler