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