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

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/>. 

13 

14"""Config file for the page that swaps words.""" 

15 

16from dataclasses import dataclass, field 

17from functools import lru_cache 

18from typing import TypeAlias 

19 

20import regex 

21from regex import Match, Pattern 

22 

23 

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. 

27 

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 ) 

36 

37 

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() 

49 

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 ) 

59 

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) 

72 

73 

74class ConfigLine: # pylint: disable=too-few-public-methods 

75 """Class used to represent a word pair.""" 

76 

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 

80 

81 

82@dataclass(frozen=True, slots=True) 

83class Comment(ConfigLine): 

84 """Class used to represent a comment.""" 

85 

86 comment: str 

87 

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}" 

91 

92 

93@dataclass(frozen=True, init=False, slots=True) 

94class WordPair(ConfigLine): 

95 """Parent class representing a word pair.""" 

96 

97 word1: str 

98 word2: str 

99 # separator between the two words, that shouldn't be changed 

100 separator: str = field(default="", init=False) 

101 

102 def get_replacement(self, word: str) -> str: 

103 """Get the replacement for a given word with the same case.""" 

104 raise NotImplementedError 

105 

106 def len_of_left(self) -> int: 

107 """Get the length to the left of the separator.""" 

108 return len(self.word1) 

109 

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 ) 

122 

123 def to_pattern_str(self) -> str: 

124 """Get the pattern that matches the replaceable words.""" 

125 raise NotImplementedError 

126 

127 

128@dataclass(frozen=True, slots=True) 

129class OneWayPair(WordPair): 

130 """Class used to represent a word that gets replaced with another.""" 

131 

132 # separator between the two words, that shouldn't be changed 

133 separator: str = field(default=" =>", init=False) 

134 

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 

142 

143 def to_pattern_str(self) -> str: 

144 """Get the pattern that matches the replaceable words.""" 

145 return self.word1 

146 

147 

148@dataclass(frozen=True, slots=True) 

149class TwoWayPair(WordPair): 

150 """Class used to represent two words that get replaced with each other.""" 

151 

152 # separator between the two words, that shouldn't be changed 

153 separator: str = field(default="<=>", init=False) 

154 

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 

168 

169 def to_pattern_str(self) -> str: 

170 """Get the pattern that matches the replaceable words.""" 

171 return f"{self.word1}|{self.word2}" 

172 

173 

174WORDS_TUPLE: TypeAlias = tuple[ConfigLine, ...] # pylint: disable=invalid-name 

175 

176LINE_END_REGEX: Pattern[str] = regex.compile( 

177 r"(?m)[\n;]", # ";" or new line 

178) 

179 

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) 

188 

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) 

208 

209 

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() 

217 

218 if not line: 

219 return Comment("") # empty comment → empty line 

220 

221 if match := regex.fullmatch(COMMENT_LINE_REGEX, line): 

222 return Comment(match[1]) 

223 

224 match = regex.fullmatch(LINE_REGEX, line) 

225 if match is None: 

226 raise InvalidConfigError(line_num, line, "Line is invalid.") 

227 

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.") 

237 

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 

245 

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) 

259 

260 raise InvalidConfigError(line_num, line, "Something went wrong.") 

261 

262 

263@dataclass(frozen=True, slots=True) 

264class InvalidConfigError(Exception): 

265 """Exception raised if the config is invalid.""" 

266 

267 line_num: int 

268 line: str 

269 reason: str 

270 

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 ) 

277 

278 

279class SwappedWordsConfig: # pylint: disable=eq-without-hash 

280 """SwappedWordsConfig class used to swap words in strings.""" 

281 

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 

287 

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 ) 

297 

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 ) 

310 

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] 

318 

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) 

327 

328 return word 

329 

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) 

333 

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 ) 

355 

356 

357def minify(config: str) -> str: 

358 """Minify a config string.""" 

359 return SwappedWordsConfig(config).to_config_str(True) 

360 

361 

362def beautify(config: str) -> str: 

363 """Beautify a config string.""" 

364 return SwappedWordsConfig(config).to_config_str()