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

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 __future__ import annotations 

17 

18from dataclasses import dataclass, field 

19from functools import lru_cache 

20from typing import TypeAlias 

21 

22import regex 

23from regex import Match, Pattern 

24 

25 

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. 

29 

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 ) 

38 

39 

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

51 

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 ) 

61 

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) 

74 

75 

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

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

78 

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 

82 

83 

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

85class Comment(ConfigLine): 

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

87 

88 comment: str 

89 

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

93 

94 

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

96class WordPair(ConfigLine): 

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

98 

99 word1: str 

100 word2: str 

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

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

103 

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

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

106 raise NotImplementedError 

107 

108 def len_of_left(self) -> int: 

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

110 return len(self.word1) 

111 

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 ) 

124 

125 def to_pattern_str(self) -> str: 

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

127 raise NotImplementedError 

128 

129 

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

131class OneWayPair(WordPair): 

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

133 

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

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

136 

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 

144 

145 def to_pattern_str(self) -> str: 

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

147 return self.word1 

148 

149 

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

151class TwoWayPair(WordPair): 

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

153 

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

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

156 

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 

170 

171 def to_pattern_str(self) -> str: 

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

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

174 

175 

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

177 

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

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

180) 

181 

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) 

190 

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) 

210 

211 

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

219 

220 if not line: 

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

222 

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

224 return Comment(match[1]) 

225 

226 match = regex.fullmatch(LINE_REGEX, line) 

227 if match is None: 

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

229 

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

239 

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 

247 

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) 

261 

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

263 

264 

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

266class InvalidConfigError(Exception): 

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

268 

269 line_num: int 

270 line: str 

271 reason: str 

272 

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 ) 

279 

280 

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

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

283 

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 

289 

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 ) 

299 

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 ) 

312 

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] 

320 

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) 

329 

330 return word 

331 

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) 

335 

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 ) 

357 

358 

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

360 """Minify a config string.""" 

361 return SwappedWordsConfig(config).to_config_str(True) 

362 

363 

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

365 """Beautify a config string.""" 

366 return SwappedWordsConfig(config).to_config_str()