Coverage for an_website/swapped_words/swap.py: 100.000%

66 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"""A page that swaps words.""" 

15 

16from __future__ import annotations 

17 

18from asyncio import Future 

19from base64 import b64decode, b64encode 

20from dataclasses import dataclass 

21from typing import ClassVar, Final 

22 

23from tornado.web import HTTPError, MissingArgumentError 

24 

25from .. import DIR as ROOT_DIR 

26from ..utils.data_parsing import parse_args 

27from ..utils.request_handler import APIRequestHandler, HTMLRequestHandler 

28from .config_file import InvalidConfigError, SwappedWordsConfig 

29 

30# the max char count of the text to process 

31MAX_CHAR_COUNT: Final[int] = 32769 - 1 

32 

33with (ROOT_DIR / "swapped_words/config.sw").open("r", encoding="UTF-8") as file: 

34 DEFAULT_CONFIG: Final[SwappedWordsConfig] = SwappedWordsConfig(file.read()) 

35 

36 

37def check_text_too_long(text: str) -> None: 

38 """Raise an HTTP error if the text is too long.""" 

39 len_text = len(text) 

40 

41 if len_text > MAX_CHAR_COUNT: 

42 raise HTTPError( 

43 413, 

44 reason=( 

45 f"The text has {len_text} characters, but it is only allowed " 

46 f"to have {MAX_CHAR_COUNT} characters. That's " 

47 f"{len_text - MAX_CHAR_COUNT} characters too much." 

48 ), 

49 ) 

50 

51 

52@dataclass(slots=True) 

53class SwArgs: 

54 """Arguments used for swapped words.""" 

55 

56 text: str = "" 

57 config: None | str = None 

58 reset: bool = False 

59 return_config: bool = False 

60 minify_config: bool = False 

61 

62 def validate(self) -> None: 

63 """Validate the given data.""" 

64 self.text = self.text.strip() 

65 check_text_too_long(self.text) 

66 if self.config: 

67 self.config = self.config.strip() 

68 

69 def validate_require_text(self) -> None: 

70 """Validate the given data and require the text argument.""" 

71 self.validate() 

72 if not self.text: 

73 raise MissingArgumentError("text") 

74 

75 

76class SwappedWords(HTMLRequestHandler): 

77 """The request handler for the swapped words page.""" 

78 

79 @parse_args(type_=SwArgs, validation_method="validate") 

80 async def get(self, *, head: bool = False, args: SwArgs) -> None: 

81 """Handle GET requests to the swapped words page.""" 

82 # pylint: disable=unused-argument 

83 await self.handle_text(args) 

84 

85 def handle_text(self, args: SwArgs) -> Future[None]: 

86 """Use the text to display the HTML page.""" 

87 if args.config is None: 

88 cookie = self.get_cookie( 

89 "swapped-words-config", 

90 None, 

91 ) 

92 if cookie is not None: 

93 # decode the base64 text 

94 args.config = b64decode(cookie).decode("UTF-8") 

95 else: 

96 # save the config in a cookie 

97 self.set_cookie( 

98 name="swapped-words-config", 

99 # encode the config as base64 

100 value=b64encode(args.config.encode("UTF-8")).decode("UTF-8"), 

101 expires_days=1000, 

102 path=self.request.path, 

103 samesite="Strict", 

104 ) 

105 

106 try: 

107 sw_config = ( 

108 DEFAULT_CONFIG 

109 if args.config is None or args.reset 

110 else SwappedWordsConfig(args.config) 

111 ) 

112 except InvalidConfigError as exc: 

113 self.set_status(400) 

114 return self.render( 

115 "pages/swapped_words.html", 

116 text=args.text, 

117 output="", 

118 config=args.config, 

119 MAX_CHAR_COUNT=MAX_CHAR_COUNT, 

120 error_msg=str(exc), 

121 ) 

122 # everything went well 

123 return self.render( 

124 "pages/swapped_words.html", 

125 text=args.text, 

126 output=sw_config.swap_words(args.text), 

127 config=sw_config.to_config_str(), 

128 MAX_CHAR_COUNT=MAX_CHAR_COUNT, 

129 error_msg=None, 

130 ) 

131 

132 @parse_args(type_=SwArgs, validation_method="validate_require_text") 

133 async def post(self, *, args: SwArgs) -> None: 

134 """Handle POST requests to the swapped words page.""" 

135 await self.handle_text(args) 

136 

137 

138class SwappedWordsAPI(APIRequestHandler): 

139 """The request handler for the swapped words API.""" 

140 

141 ALLOWED_METHODS: ClassVar[tuple[str, ...]] = ("GET", "POST") 

142 

143 @parse_args(type_=SwArgs, validation_method="validate") 

144 async def get(self, *, head: bool = False, args: SwArgs) -> None: 

145 """Handle GET requests to the swapped words API.""" 

146 # pylint: disable=unused-argument 

147 try: 

148 sw_config = ( 

149 DEFAULT_CONFIG 

150 if args.config is None 

151 else SwappedWordsConfig(args.config) 

152 ) 

153 

154 if args.return_config: 

155 return await self.finish_dict( 

156 text=args.text, 

157 return_config=True, 

158 minify_config=args.minify_config, 

159 config=sw_config.to_config_str(args.minify_config), 

160 replaced_text=sw_config.swap_words(args.text), 

161 ) 

162 return await self.finish_dict( 

163 text=args.text, 

164 return_config=False, 

165 replaced_text=sw_config.swap_words(args.text), 

166 ) 

167 except InvalidConfigError as exc: 

168 self.set_status(400) 

169 return await self.finish_dict( 

170 error=exc.reason, 

171 line=exc.line, 

172 line_num=exc.line_num, 

173 ) 

174 

175 async def post(self) -> None: 

176 """Handle POST requests to the swapped words API.""" 

177 return await self.get() # pylint: disable=missing-kwoa