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