Coverage for an_website / swapped_words / config_file.py: 97.143%
140 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-24 17:35 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-24 17:35 +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 dataclasses import dataclass, field
17from functools import lru_cache
18from typing import TypeAlias
20import regex
21from regex import Match, Pattern
24def copy_case_letter(char_to_steal_case_from: str, char_to_change: str) -> str:
25 """
26 Copy the case of one string to another.
28 This method assumes that the whole string has the same case,
29 like it is the case for a letter.
30 """
31 return (
32 char_to_change.upper() # char_to_steal_case_from is upper case
33 if char_to_steal_case_from.isupper()
34 else char_to_change.lower() # char_to_steal_case_from is lower case
35 )
38def copy_case(reference_word: str, word_to_change: str) -> str:
39 """Copy the case of one string to another."""
40 # lower case: "every letter is lower case"
41 if reference_word.islower():
42 return word_to_change.lower()
43 # upper case: "EVERY LETTER IS UPPER CASE"
44 if reference_word.isupper():
45 return word_to_change.upper()
46 # title case: "Every Word Begins With Upper Case Letter"
47 if reference_word.istitle():
48 return word_to_change.title()
50 split_ref = reference_word.split(" ")
51 split_word = word_to_change.split(" ")
52 # if both equal length and not len == 1
53 if len(split_ref) == len(split_word) != 1:
54 # go over every word, if there are spaces
55 return " ".join(
56 copy_case(split_ref[i], split_word[i])
57 for i in range(len(split_ref))
58 )
60 # other words
61 new_word: list[str] = [] # use list for speed
62 for i, letter in enumerate(word_to_change):
63 new_word.append(
64 copy_case_letter(
65 # overflow original word for mixed case
66 reference_word[i % len(reference_word)],
67 letter,
68 )
69 )
70 # create new word and return it
71 return "".join(new_word)
74class ConfigLine: # pylint: disable=too-few-public-methods
75 """Class used to represent a word pair."""
77 def to_conf_line(self, len_of_left: None | int = None) -> str:
78 """Get how this would look like in a config."""
79 raise NotImplementedError
82@dataclass(frozen=True, slots=True)
83class Comment(ConfigLine):
84 """Class used to represent a comment."""
86 comment: str
88 def to_conf_line(self, len_of_left: None | int = None) -> str:
89 """Get how this would look like in a config."""
90 return "" if not self.comment else f"# {self.comment}"
93@dataclass(frozen=True, init=False, slots=True)
94class WordPair(ConfigLine):
95 """Parent class representing a word pair."""
97 word1: str
98 word2: str
99 # separator between the two words, that shouldn't be changed
100 separator: str = field(default="", init=False)
102 def get_replacement(self, word: str) -> str:
103 """Get the replacement for a given word with the same case."""
104 raise NotImplementedError
106 def len_of_left(self) -> int:
107 """Get the length to the left of the separator."""
108 return len(self.word1)
110 def to_conf_line(self, len_of_left: None | int = None) -> str:
111 """Get how this would look like in a config."""
112 left, separator, right = self.word1, self.separator, self.word2
113 if len_of_left is None:
114 return "".join((left, separator.strip(), right))
115 return (
116 left
117 + (" " * (1 + len_of_left - self.len_of_left()))
118 + separator
119 + " "
120 + right
121 )
123 def to_pattern_str(self) -> str:
124 """Get the pattern that matches the replaceable words."""
125 raise NotImplementedError
128@dataclass(frozen=True, slots=True)
129class OneWayPair(WordPair):
130 """Class used to represent a word that gets replaced with another."""
132 # separator between the two words, that shouldn't be changed
133 separator: str = field(default=" =>", init=False)
135 def get_replacement(self, word: str) -> str:
136 """Get the replacement for a given word with the same case."""
137 if regex.fullmatch(self.word1, word) is not None:
138 return self.word2
139 if regex.fullmatch(self.word1, word, regex.IGNORECASE) is not None:
140 return copy_case(word, self.word2)
141 return word
143 def to_pattern_str(self) -> str:
144 """Get the pattern that matches the replaceable words."""
145 return self.word1
148@dataclass(frozen=True, slots=True)
149class TwoWayPair(WordPair):
150 """Class used to represent two words that get replaced with each other."""
152 # separator between the two words, that shouldn't be changed
153 separator: str = field(default="<=>", init=False)
155 def get_replacement(self, word: str) -> str:
156 """Get the replacement for a given word with the same case."""
157 if self.word1 == word:
158 return self.word2
159 if self.word2 == word:
160 return self.word1
161 # doesn't match case sensitive
162 word_lower = word.lower()
163 if self.word1.lower() == word_lower:
164 return copy_case(word, self.word2)
165 if self.word2.lower() == word_lower:
166 return copy_case(word, self.word1)
167 return word
169 def to_pattern_str(self) -> str:
170 """Get the pattern that matches the replaceable words."""
171 return f"{self.word1}|{self.word2}"
174WORDS_TUPLE: TypeAlias = tuple[ConfigLine, ...] # pylint: disable=invalid-name
176LINE_END_REGEX: Pattern[str] = regex.compile(
177 r"(?m)[\n;]", # ";" or new line
178)
180COMMENT_LINE_REGEX: Pattern[str] = regex.compile(
181 r"#" # the start of the comment
182 r"\s*" # optional white space
183 r"(" # start of the group
184 r"[^\s]" # a non white space char (start of comment)
185 r".*" # whatever
186 r")?" # end group, make it optional, to allow lines with only #
187)
189LINE_REGEX: Pattern[str] = regex.compile(
190 r"\s*" # white spaces to strip the word
191 r"(" # start group one for the first word
192 r"[^\s<=>]" # the start of the word; can't contain: \s, "<", "=", ">"
193 r"[^<=>]*" # the middle of the word; can't contain: "<", "=", ">"="
194 r"[^\s<=>]" # the end of the word; can't contain: \s, "<", "=", ">"
195 r"|[^\s<=>]?" # one single letter word; optional -> better error message
196 r")" # end group one for the first word
197 r"\s*" # white spaces to strip the word
198 r"(<?=>)" # the seperator in the middle either "=>" or "<=>"
199 r"\s*" # white spaces to strip the word
200 r"(" # start group two for the second word
201 r"[^\s<=>]" # the start of the word; can't contain: \s, "<", "=", ">"
202 r"[^<=>]*" # the middle of the word; can't contain: "<", "=", ">"
203 r"[^\s<=>]" # the end of the word; can't contain: \s, "<", "=", ">"
204 r"|[^\s<=>]?" # one single letter word; optional -> better error message
205 r")" # end group two for the second word
206 r"\s*" # white spaces to strip the word
207)
210@lru_cache(20)
211def parse_config_line( # pylint: disable=too-complex
212 line: str, line_num: int = -1
213) -> ConfigLine:
214 """Parse one config line to one ConfigLine instance."""
215 # remove white spaces to fix stuff, behaves weird otherwise
216 line = line.strip()
218 if not line:
219 return Comment("") # empty comment → empty line
221 if match := regex.fullmatch(COMMENT_LINE_REGEX, line):
222 return Comment(match[1])
224 match = regex.fullmatch(LINE_REGEX, line)
225 if match is None:
226 raise InvalidConfigError(line_num, line, "Line is invalid.")
228 left, separator, right = match[1], match[2], match[3]
229 if not left:
230 raise InvalidConfigError(line_num, line, "Left of separator is empty.")
231 if separator not in ("<=>", "=>"):
232 raise InvalidConfigError(
233 line_num, line, "No separator ('<=>' or '=>') present."
234 )
235 if not right:
236 raise InvalidConfigError(line_num, line, "Right of separator is empty.")
238 try:
239 # compile to make sure it doesn't break later
240 left_re = regex.compile(left)
241 except regex.error as exc:
242 raise InvalidConfigError(
243 line_num, line, f"Left is invalid regex: {exc}"
244 ) from exc
246 if separator == "<=>":
247 try:
248 # compile to make sure it doesn't break later
249 right_re = regex.compile(right)
250 except regex.error as exc:
251 raise InvalidConfigError(
252 line_num, line, f"Right is invalid regex: {exc}"
253 ) from exc
254 # pylint: disable=too-many-function-args
255 return TwoWayPair(left_re.pattern, right_re.pattern)
256 if separator == "=>":
257 # pylint: disable=too-many-function-args
258 return OneWayPair(left_re.pattern, right)
260 raise InvalidConfigError(line_num, line, "Something went wrong.")
263@dataclass(frozen=True, slots=True)
264class InvalidConfigError(Exception):
265 """Exception raised if the config is invalid."""
267 line_num: int
268 line: str
269 reason: str
271 def __str__(self) -> str:
272 """Exception to str."""
273 return (
274 f"Error in line {self.line_num}: {self.line.strip()!r} "
275 f"with reason: {self.reason}"
276 )
279class SwappedWordsConfig: # pylint: disable=eq-without-hash
280 """SwappedWordsConfig class used to swap words in strings."""
282 def __eq__(self, other: object) -> bool:
283 """Check equality based on the lines."""
284 if not isinstance(other, SwappedWordsConfig):
285 return NotImplemented
286 return self.lines == other.lines
288 def __init__(self, config: str):
289 """Parse a config string to a instance of this class."""
290 self.lines: WORDS_TUPLE = tuple(
291 parse_config_line(line, i)
292 for i, line in enumerate(
293 regex.split(LINE_END_REGEX, config.strip())
294 )
295 if line
296 )
298 def get_regex(self) -> Pattern[str]:
299 """Get the regex that matches every word in this."""
300 return regex.compile(
301 "|".join(
302 tuple(
303 f"(?P<n{i}>{word_pair.to_pattern_str()})"
304 for i, word_pair in enumerate(self.lines)
305 if isinstance(word_pair, WordPair)
306 )
307 ),
308 regex.IGNORECASE,
309 )
311 def get_replaced_word(self, match: Match[str]) -> str:
312 """Get the replaced word with the same case as the match."""
313 for key, word in match.groupdict().items():
314 if isinstance(word, str) and key.startswith("n"):
315 return self.get_replacement_by_group_name(key, word)
316 # if an unknown error happens return the match to change nothing
317 return match[0]
319 def get_replacement_by_group_name(self, group_name: str, word: str) -> str:
320 """Get the replacement of a word by the group name it matched."""
321 if not group_name.startswith("n") or group_name == "n":
322 return word
323 index = int(group_name[1:])
324 config_line = self.lines[index]
325 if isinstance(config_line, WordPair):
326 return config_line.get_replacement(word)
328 return word
330 def swap_words(self, text: str) -> str:
331 """Swap the words in the text."""
332 return self.get_regex().sub(self.get_replaced_word, text)
334 def to_config_str(self, minified: bool = False) -> str:
335 """Create a readable config str from this."""
336 if not self.lines:
337 return ""
338 if minified:
339 return ";".join(
340 word_pair.to_conf_line()
341 for word_pair in self.lines
342 if isinstance(word_pair, WordPair)
343 )
344 max_len = max(
345 (
346 word_pair.len_of_left()
347 for word_pair in self.lines
348 if isinstance(word_pair, WordPair)
349 ),
350 default=0,
351 )
352 return "\n".join(
353 word_pair.to_conf_line(max_len) for word_pair in self.lines
354 )
357def minify(config: str) -> str:
358 """Minify a config string."""
359 return SwappedWordsConfig(config).to_config_str(True)
362def beautify(config: str) -> str:
363 """Beautify a config string."""
364 return SwappedWordsConfig(config).to_config_str()