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
« 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/>.
14"""A page that swaps words."""
17from asyncio import Future
18from base64 import b64decode, b64encode
19from dataclasses import dataclass
20from typing import ClassVar, Final
22from tornado.web import HTTPError, MissingArgumentError
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
29# the max char count of the text to process
30MAX_CHAR_COUNT: Final[int] = 32769 - 1
32with (ROOT_DIR / "swapped_words/config.sw").open("r", encoding="UTF-8") as file:
33 DEFAULT_CONFIG: Final[SwappedWordsConfig] = SwappedWordsConfig(file.read())
36def check_text_too_long(text: str) -> None:
37 """Raise an HTTP error if the text is too long."""
38 len_text = len(text)
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 )
51@dataclass(slots=True)
52class SwArgs:
53 """Arguments used for swapped words."""
55 text: str = ""
56 config: None | str = None
57 reset: bool = False
58 return_config: bool = False
59 minify_config: bool = False
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()
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")
75class SwappedWords(HTMLRequestHandler):
76 """The request handler for the swapped words page."""
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)
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 )
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 )
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)
137class SwappedWordsAPI(APIRequestHandler):
138 """The request handler for the swapped words API."""
140 ALLOWED_METHODS: ClassVar[tuple[str, ...]] = ("GET", "POST")
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 )
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 )
174 async def post(self) -> None:
175 """Handle POST requests to the swapped words API."""
176 return await self.get() # pylint: disable=missing-kwoa