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

65 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-10 18: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 asyncio import Future 

17from base64 import b64decode, b64encode 

18from dataclasses import dataclass 

19from typing import ClassVar, Final 

20 

21from tornado.web import HTTPError, MissingArgumentError 

22 

23from .. import DIR as ROOT_DIR 

24from ..utils.data_parsing import parse_args 

25from ..utils.request_handler import APIRequestHandler, HTMLRequestHandler 

26from .config_file import InvalidConfigError, SwappedWordsConfig 

27 

28# the max char count of the text to process 

29MAX_CHAR_COUNT: Final[int] = 32769 - 1 

30 

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

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

33 

34 

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

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

37 len_text = len(text) 

38 

39 if len_text > MAX_CHAR_COUNT: 

40 raise HTTPError( 

41 413, 

42 reason=( 

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

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

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

46 ), 

47 ) 

48 

49 

50@dataclass(slots=True) 

51class SwArgs: 

52 """Arguments used for swapped words.""" 

53 

54 text: str = "" 

55 config: None | str = None 

56 reset: bool = False 

57 return_config: bool = False 

58 minify_config: bool = False 

59 

60 def validate(self) -> None: 

61 """Validate the given data.""" 

62 self.text = self.text.strip() 

63 check_text_too_long(self.text) 

64 if self.config: 

65 self.config = self.config.strip() 

66 

67 def validate_require_text(self) -> None: 

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

69 self.validate() 

70 if not self.text: 

71 raise MissingArgumentError("text") 

72 

73 

74class SwappedWords(HTMLRequestHandler): 

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

76 

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

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

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

80 # pylint: disable=unused-argument 

81 await self.handle_text(args) 

82 

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

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

85 if args.config is None: 

86 cookie = self.get_cookie( 

87 "swapped-words-config", 

88 None, 

89 ) 

90 if cookie is not None: 

91 # decode the base64 text 

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

93 else: 

94 # save the config in a cookie 

95 self.set_cookie( 

96 name="swapped-words-config", 

97 # encode the config as base64 

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

99 expires_days=1000, 

100 path=self.request.path, 

101 samesite="Strict", 

102 ) 

103 

104 try: 

105 sw_config = ( 

106 DEFAULT_CONFIG 

107 if args.config is None or args.reset 

108 else SwappedWordsConfig(args.config) 

109 ) 

110 except InvalidConfigError as exc: 

111 self.set_status(400) 

112 return self.render( 

113 "pages/swapped_words.html", 

114 text=args.text, 

115 output="", 

116 config=args.config, 

117 MAX_CHAR_COUNT=MAX_CHAR_COUNT, 

118 error_msg=str(exc), 

119 ) 

120 # everything went well 

121 return self.render( 

122 "pages/swapped_words.html", 

123 text=args.text, 

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

125 config=sw_config.to_config_str(), 

126 MAX_CHAR_COUNT=MAX_CHAR_COUNT, 

127 error_msg=None, 

128 ) 

129 

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

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

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

133 await self.handle_text(args) 

134 

135 

136class SwappedWordsAPI(APIRequestHandler): 

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

138 

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

140 

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

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

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

144 # pylint: disable=unused-argument 

145 try: 

146 sw_config = ( 

147 DEFAULT_CONFIG 

148 if args.config is None 

149 else SwappedWordsConfig(args.config) 

150 ) 

151 

152 if args.return_config: 

153 return await self.finish_dict( 

154 text=args.text, 

155 return_config=True, 

156 minify_config=args.minify_config, 

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

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

159 ) 

160 return await self.finish_dict( 

161 text=args.text, 

162 return_config=False, 

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

164 ) 

165 except InvalidConfigError as exc: 

166 self.set_status(400) 

167 return await self.finish_dict( 

168 error=exc.reason, 

169 line=exc.line, 

170 line_num=exc.line_num, 

171 ) 

172 

173 async def post(self) -> None: 

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

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