Coverage for an_website/swapped_words/config_file.py: 96.599%
147 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"""Config file for the page that swaps words."""
16from __future__ import annotations
18from dataclasses import dataclass, field
19from functools import lru_cache
20from typing import TypeAlias
22import regex
23from regex import Match, Pattern
26def copy_case_letter(char_to_steal_case_from: str, char_to_change: str) -> str:
27 """
28 Copy the case of one string to another.
30 This method assumes that the whole string has the same case,
31 like it is the case for a letter.
32 """
33 return (
34 char_to_change.upper() # char_to_steal_case_from is upper case
35 if char_to_steal_case_from.isupper()
36 else char_to_change.lower() # char_to_steal_case_from is lower case
37 )
40def copy_case(reference_word: str, word_to_change: str) -> str:
41 """Copy the case of one string to another."""
42 # lower case: "every letter is lower case"
43 if reference_word.islower():
44 return word_to_change.lower()
45 # upper case: "EVERY LETTER IS UPPER CASE"
46 if reference_word.isupper():
47 return word_to_change.upper()
48 # title case: "Every Word Begins With Upper Case Letter"
49 if reference_word.istitle():
50 return word_to_change.title()
52 split_ref = reference_word.split(" ")
53 split_word = word_to_change.split(" ")
54 # if both equal length and not len == 1
55 if len(split_ref) == len(split_word) != 1:
56 # go over every word, if there are spaces
57 return " ".join(
58 copy_case(split_ref[i], split_word[i])
59 for i in range(len(split_ref))
60 )
62 # other words
63 new_word: list[str] = [] # use list for speed
64 for i, letter in enumerate(word_to_change):
65 new_word.append(
66 copy_case_letter(
67 # overflow original word for mixed case
68 reference_word[i % len(reference_word)],
69 letter,
70 )
71 )
72 # create new word and return it
73 return "".join(new_word)
76class ConfigLine: # pylint: disable=too-few-public-methods
77 """Class used to represent a word pair."""
79 def to_conf_line(self, len_of_left: None | int = None) -> str:
80 """Get how this would look like in a config."""
81 raise NotImplementedError
84@dataclass(frozen=True, slots=True)
85class Comment(ConfigLine):
86 """Class used to represent a comment."""
88 comment: str
90 def to_conf_line(self, len_of_left: None | int = None) -> str:
91 """Get how this would look like in a config."""
92 return "" if not self.comment else f"# {self.comment}"
95@dataclass(frozen=True, init=False, slots=True)
96class WordPair(ConfigLine):
97 """Parent class representing a word pair."""
99 word1: str
100 word2: str
101 # separator between the two words, that shouldn't be changed
102 separator: str = field(default="", init=False)
104 def get_replacement(self, word: str) -> str:
105 """Get the replacement for a given word with the same case."""
106 raise NotImplementedError
108 def len_of_left(self) -> int:
109 """Get the length to the left of the separator."""
110 return len(self.word1)
112 def to_conf_line(self, len_of_left: None | int = None) -> str:
113 """Get how this would look like in a config."""
114 left, separator, right = self.word1, self.separator, self.word2
115 if len_of_left is None:
116 return "".join((left, separator.strip(), right))
117 return (
118 left
119 + (" " * (1 + len_of_left - self.len_of_left()))
120 + separator
121 + " "
122 + right
123 )
125 def to_pattern_str(self) -> str:
126 """Get the pattern that matches the replaceable words."""
127 raise NotImplementedError
130@dataclass(frozen=True, slots=True)
131class OneWayPair(WordPair):
132 """Class used to represent a word that gets replaced with another."""
134 # separator between the two words, that shouldn't be changed
135 separator: str = field(default=" =>", init=False)
137 def get_replacement(self, word: str) -> str:
138 """Get the replacement for a given word with the same case."""
139 if regex.fullmatch(self.word1, word) is not None:
140 return self.word2
141 if regex.fullmatch(self.word1, word, regex.IGNORECASE) is not None:
142 return copy_case(word, self.word2)
143 return word
145 def to_pattern_str(self) -> str:
146 """Get the pattern that matches the replaceable words."""
147 return self.word1
150@dataclass(frozen=True, slots=True)
151class TwoWayPair(WordPair):
152 """Class used to represent two words that get replaced with each other."""
154 # separator between the two words, that shouldn't be changed
155 separator: str = field(default="<=>", init=False)
157 def get_replacement(self, word: str) -> str:
158 """Get the replacement for a given word with the same case."""
159 if self.word1 == word:
160 return self.word2
161 if self.word2 == word:
162 return self.word1
163 # doesn't match case sensitive
164 word_lower = word.lower()
165 if self.word1.lower() == word_lower:
166 return copy_case(word, self.word2)
167 if self.word2.lower() == word_lower:
168 return copy_case(word, self.word1)
169 return word
171 def to_pattern_str(self) -> str:
172 """Get the pattern that matches the replaceable words."""
173 return f"{self.word1}|{self.word2}"
176WORDS_TUPLE: TypeAlias = tuple[ConfigLine, ...] # pylint: disable=invalid-name
178LINE_END_REGEX: Pattern[str] = regex.compile(
179 r"(?m)[\n;]", # ";" or new line
180)
182COMMENT_LINE_REGEX: Pattern[str] = regex.compile(
183 r"#" # the start of the comment
184 r"\s*" # optional white space
185 r"(" # start of the group
186 r"[^\s]" # a non white space char (start of comment)
187 r".*" # whatever
188 r")?" # end group, make it optional, to allow lines with only #
189)
191LINE_REGEX: Pattern[str] = regex.compile(
192 r"\s*" # white spaces to strip the word
193 r"(" # start group one for the first word
194 r"[^\s<=>]" # the start of the word; can't contain: \s, "<", "=", ">"
195 r"[^<=>]*" # the middle of the word; can't contain: "<", "=", ">"="
196 r"[^\s<=>]" # the end of the word; can't contain: \s, "<", "=", ">"
197 r"|[^\s<=>]?" # one single letter word; optional -> better error message
198 r")" # end group one for the first word
199 r"\s*" # white spaces to strip the word
200 r"(<?=>)" # the seperator in the middle either "=>" or "<=>"
201 r"\s*" # white spaces to strip the word
202 r"(" # start group two for the second word
203 r"[^\s<=>]" # the start of the word; can't contain: \s, "<", "=", ">"
204 r"[^<=>]*" # the middle of the word; can't contain: "<", "=", ">"
205 r"[^\s<=>]" # the end of the word; can't contain: \s, "<", "=", ">"
206 r"|[^\s<=>]?" # one single letter word; optional -> better error message
207 r")" # end group two for the second word
208 r"\s*" # white spaces to strip the word
209)
212@lru_cache(20)
213def parse_config_line( # pylint: disable=too-complex
214 line: str, line_num: int = -1
215) -> ConfigLine:
216 """Parse one config line to one ConfigLine instance."""
217 # remove white spaces to fix stuff, behaves weird otherwise
218 line = line.strip()
220 if not line:
221 return Comment("") # empty comment → empty line
223 if match := regex.fullmatch(COMMENT_LINE_REGEX, line):
224 return Comment(match[1])
226 match = regex.fullmatch(LINE_REGEX, line)
227 if match is None:
228 raise InvalidConfigError(line_num, line, "Line is invalid.")
230 left, separator, right = match[1], match[2], match[3]
231 if not left:
232 raise InvalidConfigError(line_num, line, "Left of separator is empty.")
233 if separator not in ("<=>", "=>"):
234 raise InvalidConfigError(
235 line_num, line, "No separator ('<=>' or '=>') present."
236 )
237 if not right:
238 raise InvalidConfigError(line_num, line, "Right of separator is empty.")
240 try:
241 # compile to make sure it doesn't break later
242 left_re = regex.compile(left)
243 except regex.error as exc:
244 raise InvalidConfigError(
245 line_num, line, f"Left is invalid regex: {exc}"
246 ) from exc
248 if separator == "<=>":
249 try:
250 # compile to make sure it doesn't break later
251 right_re = regex.compile(right)
252 except regex.error as exc:
253 raise InvalidConfigError(
254 line_num, line, f"Right is invalid regex: {exc}"
255 ) from exc
256 # pylint: disable=too-many-function-args
257 return TwoWayPair(left_re.pattern, right_re.pattern)
258 if separator == "=>":
259 # pylint: disable=too-many-function-args
260 return OneWayPair(left_re.pattern, right)
262 raise InvalidConfigError(line_num, line, "Something went wrong.")
265@dataclass(frozen=True, slots=True)
266class InvalidConfigError(Exception):
267 """Exception raised if the config is invalid."""
269 line_num: int
270 line: str
271 reason: str
273 def __str__(self) -> str:
274 """Exception to str."""
275 return (
276 f"Error in line {self.line_num}: {self.line.strip()!r} "
277 f"with reason: {self.reason}"
278 )
281class SwappedWordsConfig: # pylint: disable=eq-without-hash
282 """SwappedWordsConfig class used to swap words in strings."""
284 def __eq__(self, other: object) -> bool:
285 """Check equality based on the lines."""
286 if not isinstance(other, SwappedWordsConfig):
287 return NotImplemented
288 return self.lines == other.lines
290 def __init__(self, config: str):
291 """Parse a config string to a instance of this class."""
292 self.lines: WORDS_TUPLE = tuple(
293 parse_config_line(line, i)
294 for i, line in enumerate(
295 regex.split(LINE_END_REGEX, config.strip())
296 )
297 if line
298 )
300 def get_regex(self) -> Pattern[str]:
301 """Get the regex that matches every word in this."""
302 return regex.compile(
303 "|".join(
304 tuple(
305 f"(?P<n{i}>{word_pair.to_pattern_str()})"
306 for i, word_pair in enumerate(self.lines)
307 if isinstance(word_pair, WordPair)
308 )
309 ),
310 regex.IGNORECASE,
311 )
313 def get_replaced_word(self, match: Match[str]) -> str:
314 """Get the replaced word with the same case as the match."""
315 for key, word in match.groupdict().items():
316 if isinstance(word, str) and key.startswith("n"):
317 return self.get_replacement_by_group_name(key, word)
318 # if an unknown error happens return the match to change nothing
319 return match[0]
321 def get_replacement_by_group_name(self, group_name: str, word: str) -> str:
322 """Get the replacement of a word by the group name it matched."""
323 if not group_name.startswith("n") or group_name == "n":
324 return word
325 index = int(group_name[1:])
326 config_line = self.lines[index]
327 if isinstance(config_line, WordPair):
328 return config_line.get_replacement(word)
330 return word
332 def swap_words(self, text: str) -> str:
333 """Swap the words in the text."""
334 return self.get_regex().sub(self.get_replaced_word, text)
336 def to_config_str(self, minified: bool = False) -> str:
337 """Create a readable config str from this."""
338 if not self.lines:
339 return ""
340 if minified:
341 return ";".join(
342 word_pair.to_conf_line()
343 for word_pair in self.lines
344 if isinstance(word_pair, WordPair)
345 )
346 max_len = max(
347 ( # type: ignore[truthy-bool]
348 word_pair.len_of_left()
349 for word_pair in self.lines
350 if isinstance(word_pair, WordPair)
351 )
352 or (0,)
353 )
354 return "\n".join(
355 word_pair.to_conf_line(max_len) for word_pair in self.lines
356 )
359def minify(config: str) -> str:
360 """Minify a config string."""
361 return SwappedWordsConfig(config).to_config_str(True)
364def beautify(config: str) -> str:
365 """Beautify a config string."""
366 return SwappedWordsConfig(config).to_config_str()