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

65 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-05 08:06 +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 

16 

17from asyncio import Future 

18from base64 import b64decode, b64encode 

19from dataclasses import dataclass 

20from typing import ClassVar, Final 

21 

22from tornado.web import HTTPError, MissingArgumentError 

23 

24from .. import DIR as ROOT_DIR 

25from ..utils.data_parsing import parse_args 

26from ..utils.request_handler import APIRequestHandler, HTMLRequestHandler 

27from .config_file import InvalidConfigError, SwappedWordsConfig 

28 

29# the max char count of the text to process 

30MAX_CHAR_COUNT: Final[int] = 32769 - 1 

31 

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

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

34 

35 

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

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

38 len_text = len(text) 

39 

40 if len_text > MAX_CHAR_COUNT: 

41 raise HTTPError( 

42 413, 

43 reason=( 

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

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

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

47 ), 

48 ) 

49 

50 

51@dataclass(slots=True) 

52class SwArgs: 

53 """Arguments used for swapped words.""" 

54 

55 text: str = "" 

56 config: None | str = None 

57 reset: bool = False 

58 return_config: bool = False 

59 minify_config: bool = False 

60 

61 def validate(self) -> None: 

62 """Validate the given data.""" 

63 self.text = self.text.strip() 

64 check_text_too_long(self.text) 

65 if self.config: 

66 self.config = self.config.strip() 

67 

68 def validate_require_text(self) -> None: 

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

70 self.validate() 

71 if not self.text: 

72 raise MissingArgumentError("text") 

73 

74 

75class SwappedWords(HTMLRequestHandler): 

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

77 

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

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

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

81 # pylint: disable=unused-argument 

82 await self.handle_text(args) 

83 

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

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

86 if args.config is None: 

87 cookie = self.get_cookie( 

88 "swapped-words-config", 

89 None, 

90 ) 

91 if cookie is not None: 

92 # decode the base64 text 

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

94 else: 

95 # save the config in a cookie 

96 self.set_cookie( 

97 name="swapped-words-config", 

98 # encode the config as base64 

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

100 expires_days=1000, 

101 path=self.request.path, 

102 samesite="Strict", 

103 ) 

104 

105 try: 

106 sw_config = ( 

107 DEFAULT_CONFIG 

108 if args.config is None or args.reset 

109 else SwappedWordsConfig(args.config) 

110 ) 

111 except InvalidConfigError as exc: 

112 self.set_status(400) 

113 return self.render( 

114 "pages/swapped_words.html", 

115 text=args.text, 

116 output="", 

117 config=args.config, 

118 MAX_CHAR_COUNT=MAX_CHAR_COUNT, 

119 error_msg=str(exc), 

120 ) 

121 # everything went well 

122 return self.render( 

123 "pages/swapped_words.html", 

124 text=args.text, 

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

126 config=sw_config.to_config_str(), 

127 MAX_CHAR_COUNT=MAX_CHAR_COUNT, 

128 error_msg=None, 

129 ) 

130 

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

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

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

134 await self.handle_text(args) 

135 

136 

137class SwappedWordsAPI(APIRequestHandler): 

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

139 

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

141 

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

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

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

145 # pylint: disable=unused-argument 

146 try: 

147 sw_config = ( 

148 DEFAULT_CONFIG 

149 if args.config is None 

150 else SwappedWordsConfig(args.config) 

151 ) 

152 

153 if args.return_config: 

154 return await self.finish_dict( 

155 text=args.text, 

156 return_config=True, 

157 minify_config=args.minify_config, 

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

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

160 ) 

161 return await self.finish_dict( 

162 text=args.text, 

163 return_config=False, 

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

165 ) 

166 except InvalidConfigError as exc: 

167 self.set_status(400) 

168 return await self.finish_dict( 

169 error=exc.reason, 

170 line=exc.line, 

171 line_num=exc.line_num, 

172 ) 

173 

174 async def post(self) -> None: 

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

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