Coverage for an_website/utils/utils.py: 71.762%

386 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-01 14:47 +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"""A module with many useful things used by other modules.""" 

15 

16from __future__ import annotations 

17 

18import argparse 

19import asyncio 

20import bisect 

21import contextlib 

22import logging 

23import pathlib 

24import random 

25import sys 

26import time 

27from base64 import b85encode 

28from collections.abc import ( 

29 Awaitable, 

30 Callable, 

31 Collection, 

32 Generator, 

33 Iterable, 

34 Mapping, 

35 Set, 

36) 

37from dataclasses import dataclass, field 

38from datetime import datetime, timezone 

39from enum import IntFlag 

40from functools import cache, partial 

41from hashlib import sha1 

42from importlib.resources.abc import Traversable 

43from ipaddress import IPv4Address, IPv6Address, ip_address, ip_network 

44from pathlib import Path 

45from typing import ( 

46 IO, 

47 TYPE_CHECKING, 

48 Any, 

49 Final, 

50 Literal, 

51 TypeAlias, 

52 Union, 

53 cast, 

54 get_args, 

55) 

56from urllib.parse import SplitResult, parse_qsl, urlencode, urlsplit, urlunsplit 

57 

58import elasticapm 

59import regex 

60from blake3 import blake3 

61from elastic_transport import ApiError, TransportError 

62from elasticsearch import AsyncElasticsearch 

63from geoip import geolite2 # type: ignore[import-untyped] 

64from openmoji_dist import VERSION as OPENMOJI_VERSION 

65from rapidfuzz.distance.Levenshtein import distance 

66from redis.asyncio import Redis 

67from tornado.web import HTTPError, RequestHandler 

68from typed_stream import Stream 

69from UltraDict import UltraDict # type: ignore[import-untyped] 

70 

71from .. import DIR as ROOT_DIR 

72 

73if TYPE_CHECKING: 

74 from .background_tasks import BackgroundTask 

75 

76LOGGER: Final = logging.getLogger(__name__) 

77 

78# pylint: disable-next=consider-alternative-union-syntax 

79type Handler = Union[ 

80 tuple[str, type[RequestHandler]], 

81 tuple[str, type[RequestHandler], dict[str, Any]], 

82 tuple[str, type[RequestHandler], dict[str, Any], str], 

83] 

84 

85type OpenMojiValue = Literal[False, "img", "glyf_colr1", "glyf_colr0"] 

86BumpscosityValue: TypeAlias = Literal[0, 1, 12, 50, 76, 100, 1000] 

87BUMPSCOSITY_VALUES: Final[tuple[BumpscosityValue, ...]] = get_args( 

88 BumpscosityValue 

89) 

90 

91PRINT = int.from_bytes((ROOT_DIR / "primes.bin").read_bytes(), "big") 

92 

93IP_HASH_SALT: Final = { 

94 "date": datetime.now(timezone.utc).date(), 

95 "hasher": blake3( 

96 blake3( 

97 datetime.now(timezone.utc).date().isoformat().encode("ASCII") 

98 ).digest() 

99 ), 

100} 

101 

102SUS_PATHS: Final[Set[str]] = { 

103 "/-profiler/phpinfo", 

104 "/.aws/credentials", 

105 "/.env", 

106 "/.env.bak", 

107 "/.ftpconfig", 

108 "/admin/controller/extension/extension", 

109 "/assets/filemanager/dialog", 

110 "/assets/vendor/server/php", 

111 "/aws.yml", 

112 "/boaform/admin/formlogin", 

113 "/phpinfo", 

114 "/public/assets/jquery-file-upload/server/php", 

115 "/root", 

116 "/settings/aws.yml", 

117 "/uploads", 

118 "/vendor/phpunit/phpunit/src/util/php/eval-stdin", 

119 "/wordpress", 

120 "/wp", 

121 "/wp-admin", 

122 "/wp-admin/css", 

123 "/wp-includes", 

124 "/wp-login", 

125 "/wp-upload", 

126} 

127 

128 

129class ArgparseNamespace(argparse.Namespace): 

130 """A class to fake type hints for argparse.Namespace.""" 

131 

132 # pylint: disable=too-few-public-methods 

133 __slots__ = ("config", "save_config_to") 

134 

135 config: list[pathlib.Path] 

136 save_config_to: pathlib.Path | None 

137 

138 

139class AwaitableValue[T](Awaitable[T]): 

140 # pylint: disable=too-few-public-methods 

141 """An awaitable that always returns the same value.""" 

142 

143 def __await__(self) -> Generator[None, None, T]: 

144 """Return the value.""" 

145 yield 

146 return self._value 

147 

148 def __init__(self, value: T) -> None: 

149 """Set the value.""" 

150 self._value = value 

151 

152 

153class Permission(IntFlag): 

154 """Permissions for accessing restricted stuff.""" 

155 

156 RATELIMITS = 1 

157 TRACEBACK = 2 

158 BACKDOOR = 4 

159 UPDATE = 8 

160 REPORTING = 16 

161 SHORTEN = 32 

162 UPLOAD = 64 

163 

164 

165class Timer: 

166 """Timer class used for timing stuff.""" 

167 

168 __slots__ = ("_execution_time", "_start_time") 

169 

170 _execution_time: int 

171 

172 def __init__(self) -> None: 

173 """Start the timer.""" 

174 self._start_time = time.perf_counter_ns() 

175 

176 def get(self) -> float: 

177 """Get the execution time in seconds.""" 

178 return self.get_ns() / 1_000_000_000 

179 

180 def get_ns(self) -> int: 

181 """Get the execution time in nanoseconds.""" 

182 assert hasattr(self, "_execution_time"), "Timer not stopped yet" 

183 return self._execution_time 

184 

185 def stop(self) -> float: 

186 """Stop the timer and get the execution time in seconds.""" 

187 return self.stop_ns() / 1_000_000_000 

188 

189 def stop_ns(self) -> int: 

190 """Stop the timer and get the execution time in nanoseconds.""" 

191 assert not hasattr(self, "_execution_time"), "Timer already stopped" 

192 self._execution_time = time.perf_counter_ns() - self._start_time 

193 return self._execution_time 

194 

195 

196@cache 

197def add_args_to_url(url: str | SplitResult, **kwargs: object) -> str: 

198 """Add query arguments to a URL.""" 

199 if isinstance(url, str): 

200 url = urlsplit(url) 

201 

202 if not kwargs: 

203 return url.geturl() 

204 

205 url_args: dict[str, str] = dict( 

206 parse_qsl(url.query, keep_blank_values=True) 

207 ) 

208 

209 for key, value in kwargs.items(): 

210 if value is None: 

211 if key in url_args: 

212 del url_args[key] 

213 # pylint: disable-next=confusing-consecutive-elif 

214 elif isinstance(value, bool): 

215 url_args[key] = bool_to_str(value) 

216 else: 

217 url_args[key] = str(value) 

218 

219 return urlunsplit( 

220 ( 

221 url.scheme, 

222 url.netloc, 

223 url.path, 

224 urlencode(url_args), 

225 url.fragment, 

226 ) 

227 ) 

228 

229 

230def anonymize_ip[ # noqa: D103 

231 A: (str, None, str | None) 

232](address: A, *, ignore_invalid: bool = False) -> A: 

233 """Anonymize an IP address.""" 

234 if address is None: 

235 return None 

236 

237 address = address.strip() 

238 

239 try: 

240 version = ip_address(address).version 

241 except ValueError: 

242 if ignore_invalid: 

243 return address 

244 raise 

245 

246 if version == 4: 

247 return str(ip_network(address + "/24", strict=False).network_address) 

248 if version == 6: 

249 return str(ip_network(address + "/48", strict=False).network_address) 

250 

251 raise HTTPError(reason="ERROR: -41") 

252 

253 

254ansi_replace = partial(regex.sub, "\033" + r"\[-?\d+[a-zA-Z]", "") 

255ansi_replace.__doc__ = "Remove ANSI escape sequences from a string." 

256 

257 

258def apm_anonymization_processor( 

259 # pylint: disable-next=unused-argument 

260 client: elasticapm.Client, 

261 event: dict[str, Any], 

262) -> dict[str, Any]: 

263 """Anonymize an APM event.""" 

264 if "context" in event and "request" in event["context"]: 

265 request = event["context"]["request"] 

266 if "url" in request and "pathname" in request["url"]: 

267 path = request["url"]["pathname"] 

268 if path == "/robots.txt" or path.lower() in SUS_PATHS: 

269 return event 

270 if "socket" in request and "remote_address" in request["socket"]: 

271 request["socket"]["remote_address"] = anonymize_ip( 

272 request["socket"]["remote_address"] 

273 ) 

274 if "headers" in request: 

275 headers = request["headers"] 

276 if "X-Forwarded-For" in headers: 

277 headers["X-Forwarded-For"] = ", ".join( 

278 anonymize_ip(ip.strip(), ignore_invalid=True) 

279 for ip in headers["X-Forwarded-For"].split(",") 

280 ) 

281 for header in headers: 

282 if "ip" in header.lower().split("-"): 

283 headers[header] = anonymize_ip( 

284 headers[header], ignore_invalid=True 

285 ) 

286 return event 

287 

288 

289def apply[V, Ret](value: V, fun: Callable[[V], Ret]) -> Ret: # noqa: D103 

290 """Apply a function to a value and return the result.""" 

291 return fun(value) 

292 

293 

294backspace_replace = partial(regex.sub, ".?\x08", "") 

295backspace_replace.__doc__ = "Remove backspaces from a string." 

296 

297 

298def bool_to_str(val: bool) -> str: 

299 """Convert a boolean to sure/nope.""" 

300 return "sure" if val else "nope" 

301 

302 

303def bounded_edit_distance(s1: str, s2: str, /, k: int) -> int: 

304 """Return a bounded edit distance between two strings. 

305 

306 k is the maximum number returned 

307 """ 

308 if (dist := distance(s1, s2, score_cutoff=k)) == k + 1: 

309 return k 

310 return dist 

311 

312 

313def country_code_to_flag(code: str) -> str: 

314 """Convert a two-letter ISO country code to a flag emoji.""" 

315 return "".join(chr(ord(char) + 23 * 29 * 191) for char in code.upper()) 

316 

317 

318def create_argument_parser() -> argparse.ArgumentParser: 

319 """Parse command line arguments.""" 

320 parser = argparse.ArgumentParser() 

321 parser.add_argument( 

322 "-c", 

323 "--config", 

324 default=[pathlib.Path("config.ini")], 

325 help="the path to the config file", 

326 metavar="PATH", 

327 nargs="*", 

328 type=pathlib.Path, 

329 ) 

330 parser.add_argument( 

331 "--save-config-to", 

332 default=None, 

333 help="save the configuration to a file", 

334 metavar="Path", 

335 nargs="?", 

336 type=pathlib.Path, 

337 ) 

338 return parser 

339 

340 

341def emoji2html(emoji: str) -> str: 

342 """Convert an emoji to HTML.""" 

343 return f"<img src={emoji2url(emoji)!r} alt={emoji!r} class='emoji'>" 

344 

345 

346def emoji2url(emoji: str) -> str: 

347 """Convert an emoji to an URL.""" 

348 if len(emoji) == 2: 

349 emoji = emoji.removesuffix("\uFE0F") 

350 code = "-".join(f"{ord(c):04x}" for c in emoji) 

351 return f"/static/openmoji/svg/{code.upper()}.svg?v={OPENMOJI_VERSION}" 

352 

353 

354EMOJI_MAPPING: Final[Mapping[str, str]] = { 

355 "⁉": "⁉", 

356 "‼": "‼", 

357 "?": "❓", 

358 "!": "❗", 

359 "-": "➖", 

360 "+": "➕", 

361 "\U0001F51F": "\U0001F51F", 

362} 

363 

364 

365def emojify(string: str) -> Iterable[str]: 

366 """Emojify a given string.""" 

367 non_emojis: list[str] = [] 

368 for ch in ( 

369 replace_umlauts(string) 

370 .replace("!?", "⁉") 

371 .replace("!!", "‼") 

372 .replace("10", "\U0001F51F") 

373 ): 

374 emoji: str | None = None 

375 if ch.isascii(): 

376 if ch.isdigit() or ch in "#*": 

377 emoji = f"{ch}\uFE0F\u20E3" 

378 elif ch.isalpha(): 

379 emoji = country_code_to_flag(ch) 

380 emoji = EMOJI_MAPPING.get(ch, emoji) 

381 

382 if emoji is None: 

383 non_emojis.append(ch) 

384 else: 

385 if non_emojis: 

386 yield "".join(non_emojis) 

387 non_emojis.clear() 

388 yield emoji 

389 

390 if non_emojis: 

391 yield "".join(non_emojis) 

392 

393 

394async def geoip( 

395 ip: None | str, 

396 database: str = "GeoLite2-City.mmdb", 

397 elasticsearch: None | AsyncElasticsearch = None, 

398 *, 

399 allow_fallback: bool = True, 

400 caches: dict[str, dict[str, dict[str, Any]]] = UltraDict(), # noqa: B008 

401) -> None | dict[str, Any]: 

402 """Get GeoIP information.""" 

403 # pylint: disable=too-complex 

404 if not ip: 

405 return None 

406 

407 # pylint: disable-next=redefined-outer-name 

408 cache = caches.get(ip, {}) 

409 if database not in cache: 

410 if not elasticsearch: 

411 if allow_fallback and database in { 

412 "GeoLite2-City.mmdb", 

413 "GeoLite2-Country.mmdb", 

414 }: 

415 return geoip_fallback( 

416 ip, country=database == "GeoLite2-City.mmdb" 

417 ) 

418 return None 

419 

420 properties: None | tuple[str, ...] 

421 if database == "GeoLite2-City.mmdb": 

422 properties = ( 

423 "continent_name", 

424 "country_iso_code", 

425 "country_name", 

426 "region_iso_code", 

427 "region_name", 

428 "city_name", 

429 "location", 

430 "timezone", 

431 ) 

432 elif database == "GeoLite2-Country.mmdb": 

433 properties = ( 

434 "continent_name", 

435 "country_iso_code", 

436 "country_name", 

437 ) 

438 elif database == "GeoLite2-ASN.mmdb": 

439 properties = ("asn", "network", "organization_name") 

440 else: 

441 properties = None 

442 

443 try: 

444 cache[database] = ( 

445 await elasticsearch.ingest.simulate( 

446 pipeline={ 

447 "processors": [ 

448 { 

449 "geoip": { 

450 "field": "ip", 

451 "database_file": database, 

452 "properties": properties, 

453 } 

454 } 

455 ] 

456 }, 

457 docs=[{"_source": {"ip": ip}}], 

458 filter_path="docs.doc._source", 

459 ) 

460 )["docs"][0]["doc"]["_source"].get("geoip", {}) 

461 except (ApiError, TransportError): 

462 if allow_fallback and database in { 

463 "GeoLite2-City.mmdb", 

464 "GeoLite2-Country.mmdb", 

465 }: 

466 return geoip_fallback( 

467 ip, country=database == "GeoLite2-City.mmdb" 

468 ) 

469 raise 

470 

471 if "country_iso_code" in cache[database]: 

472 cache[database]["country_flag"] = country_code_to_flag( 

473 cache[database]["country_iso_code"] 

474 ) 

475 

476 caches[ip] = cache 

477 return cache[database] 

478 

479 

480def geoip_fallback(ip: str, country: bool = False) -> None | dict[str, Any]: 

481 """Get GeoIP information without using Elasticsearch.""" 

482 if not (info := geolite2.lookup(ip)): 

483 return None 

484 

485 info_dict = info.get_info_dict() 

486 

487 continent_name = info_dict.get("continent", {}).get("names", {}).get("en") 

488 country_iso_code = info_dict.get("country", {}).get("iso_code") 

489 country_name = info_dict.get("country", {}).get("names", {}).get("en") 

490 

491 data = { 

492 "continent_name": continent_name, 

493 "country_iso_code": country_iso_code, 

494 "country_name": country_name, 

495 } 

496 

497 if data["country_iso_code"]: 

498 data["country_flag"] = country_code_to_flag(data["country_iso_code"]) 

499 

500 if country: 

501 for key, value in tuple(data.items()): 

502 if not value: 

503 del data[key] 

504 

505 return data 

506 

507 latitude = info_dict.get("location", {}).get("latitude") 

508 longitude = info_dict.get("location", {}).get("longitude") 

509 location = (latitude, longitude) if latitude and longitude else None 

510 time_zone = info_dict.get("location", {}).get("time_zone") 

511 

512 data.update({"location": location, "timezone": time_zone}) 

513 

514 for key, value in tuple(data.items()): 

515 if not value: 

516 del data[key] 

517 

518 return data 

519 

520 

521def get_arguments_without_help() -> tuple[str, ...]: 

522 """Get arguments without help.""" 

523 return tuple(arg for arg in sys.argv[1:] if arg not in {"-h", "--help"}) 

524 

525 

526def get_close_matches( # based on difflib.get_close_matches 

527 word: str, 

528 possibilities: Iterable[str], 

529 count: int = 3, 

530 cutoff: float = 0.5, 

531) -> tuple[str, ...]: 

532 """Use normalized_distance to return list of the best "good enough" matches. 

533 

534 word is a sequence for which close matches are desired (typically a string). 

535 

536 possibilities is a list of sequences against which to match word 

537 (typically a list of strings). 

538 

539 Optional arg count (default 3) is the maximum number of close matches to 

540 return. count must be > 0. 

541 

542 Optional arg cutoff (default 0.5) is a float in [0, 1]. Possibilities 

543 that don't score at least that similar to word are ignored. 

544 

545 The best (no more than count) matches among the possibilities are returned 

546 in a tuple, sorted by similarity score, most similar first. 

547 """ 

548 if count <= 0: 

549 raise ValueError(f"count must be > 0: {count}") 

550 if not 0.0 <= cutoff <= 1.0: 

551 raise ValueError(f"cutoff must be in [0.0, 1.0]: {cutoff}") 

552 word_len = len(word) 

553 if not word_len: 

554 if cutoff < 1.0: 

555 return () 

556 return Stream(possibilities).limit(count).collect(tuple) 

557 result: list[tuple[float, str]] = [] 

558 for possibility in possibilities: 

559 if max_dist := max(word_len, len(possibility)): 

560 dist = bounded_edit_distance( 

561 possibility, word, 1 + int(cutoff * max_dist) 

562 ) 

563 if (ratio := dist / max_dist) <= cutoff: 

564 bisect.insort(result, (ratio, possibility)) 

565 if len(result) > count: 

566 result.pop(-1) 

567 # Strip scores for the best count matches 

568 return tuple(word for score, word in result) 

569 

570 

571def hash_bytes(*args: bytes, hasher: Any = None, size: int = 32) -> str: 

572 """Hash bytes and return the Base85 representation.""" 

573 digest: bytes 

574 if not hasher: 

575 hasher = blake3() 

576 for arg in args: 

577 hasher.update(arg) 

578 digest = ( 

579 hasher.digest(size) 

580 if isinstance(hasher, blake3) 

581 else hasher.digest()[:size] 

582 ) 

583 return b85encode(digest).decode("ASCII") 

584 

585 

586def hash_ip( 

587 address: None | str | IPv4Address | IPv6Address, size: int = 32 

588) -> str: 

589 """Hash an IP address.""" 

590 if isinstance(address, str): 

591 address = ip_address(address) 

592 if IP_HASH_SALT["date"] != (date := datetime.now(timezone.utc).date()): 

593 IP_HASH_SALT["hasher"] = blake3( 

594 blake3(date.isoformat().encode("ASCII")).digest() 

595 ) 

596 IP_HASH_SALT["date"] = date 

597 return hash_bytes( 

598 address.packed if address else b"", 

599 hasher=IP_HASH_SALT["hasher"].copy(), # type: ignore[attr-defined] 

600 size=size, 

601 ) 

602 

603 

604def is_in_european_union(ip: None | str) -> None | bool: 

605 """Return whether the specified address is in the EU.""" 

606 if not (ip and (info := geolite2.lookup(ip))): 

607 return None 

608 

609 return cast(bool, info.get_info_dict().get("is_in_european_union", False)) 

610 

611 

612def is_prime(number: int) -> bool: 

613 """Return whether the specified number is prime.""" 

614 if not number % 2: 

615 return number == 2 

616 return bool(PRINT & (1 << (number // 2))) 

617 

618 

619def length_of_match(match: regex.Match[Any]) -> int: 

620 """Calculate the length of the regex match and return it.""" 

621 return match.end() - match.start() 

622 

623 

624def n_from_set[T](set_: Set[T], n: int) -> set[T]: # noqa: D103 

625 """Get and return n elements of the set as a new set.""" 

626 new_set = set() 

627 for i, element in enumerate(set_): 

628 if i >= n: 

629 break 

630 new_set.add(element) 

631 return new_set 

632 

633 

634def name_to_id(val: str) -> str: 

635 """Replace umlauts and whitespaces in a string to get a valid HTML id.""" 

636 return regex.sub( 

637 r"[^a-z0-9]+", 

638 "-", 

639 replace_umlauts(val).lower(), 

640 ).strip("-") 

641 

642 

643def none_to_default[T, D](value: None | T, default: D) -> D | T: # noqa: D103 

644 """Like ?? in ECMAScript.""" 

645 return default if value is None else value 

646 

647 

648def parse_bumpscosity(value: str | int | None) -> BumpscosityValue: 

649 """Parse a string to a valid bumpscosity value.""" 

650 if isinstance(value, str): 

651 with contextlib.suppress(ValueError): 

652 value = int(value, base=0) 

653 if value in BUMPSCOSITY_VALUES: 

654 return cast(BumpscosityValue, value) 

655 return random.Random(repr(value)).choice(BUMPSCOSITY_VALUES) 

656 

657 

658def parse_openmoji_arg(value: str, default: OpenMojiValue) -> OpenMojiValue: 

659 """Parse the openmoji arg into a Literal.""" 

660 value = value.lower() 

661 if value == "glyf_colr0": 

662 return "glyf_colr0" 

663 if value == "glyf_colr1": 

664 return "glyf_colr1" 

665 if value in {"i", "img"}: 

666 return "img" 

667 if value in {"n", "nope"}: 

668 return False 

669 return default 

670 

671 

672# pylint: disable-next=too-many-arguments 

673async def ratelimit( 

674 redis: Redis[str], 

675 redis_prefix: str, 

676 remote_ip: str, 

677 *, 

678 bucket: None | str, 

679 max_burst: int, 

680 count_per_period: int, 

681 period: int, 

682 tokens: int, 

683) -> tuple[bool, dict[str, str]]: 

684 """Take b1nzy to space using Redis.""" 

685 remote_ip = hash_bytes(remote_ip.encode("ASCII")) 

686 key = f"{redis_prefix}:ratelimit:{remote_ip}" 

687 if bucket: 

688 key = f"{key}:{bucket}" 

689 

690 # see: https://github.com/brandur/redis-cell#usage 

691 result = await redis.execute_command( 

692 # type: ignore[no-untyped-call] 

693 "CL.THROTTLE", 

694 key, 

695 max_burst, 

696 count_per_period, 

697 period, 

698 tokens, 

699 ) 

700 

701 now = time.time() 

702 

703 headers: dict[str, str] = {} 

704 

705 if result[0]: 

706 headers["Retry-After"] = str(result[3]) 

707 if not bucket: 

708 headers["X-RateLimit-Global"] = "true" 

709 

710 if bucket: 

711 headers["X-RateLimit-Limit"] = str(result[1]) 

712 headers["X-RateLimit-Remaining"] = str(result[2]) 

713 headers["X-RateLimit-Reset"] = str(now + result[4]) 

714 headers["X-RateLimit-Reset-After"] = str(result[4]) 

715 headers["X-RateLimit-Bucket"] = hash_bytes(bucket.encode("ASCII")) 

716 

717 return bool(result[0]), headers 

718 

719 

720def remove_suffix_ignore_case(string: str, suffix: str) -> str: 

721 """Remove a suffix without caring about the case.""" 

722 if string.lower().endswith(suffix.lower()): 

723 return string[: -len(suffix)] 

724 return string 

725 

726 

727def replace_umlauts(string: str) -> str: 

728 """Replace Ä, Ö, Ü, ẞ, ä, ö, ü, ß in string.""" 

729 if string.isupper(): 

730 return ( 

731 string.replace("Ä", "AE") 

732 .replace("Ö", "OE") 

733 .replace("Ü", "UE") 

734 .replace("ẞ", "SS") 

735 ) 

736 if " " in string: 

737 return " ".join(replace_umlauts(word) for word in string.split(" ")) 

738 return ( 

739 string.replace("ä", "ae") 

740 .replace("ö", "oe") 

741 .replace("ü", "ue") 

742 .replace("ß", "ss") 

743 .replace("Ä", "Ae") 

744 .replace("Ö", "Oe") 

745 .replace("Ü", "Ue") 

746 .replace("ẞ", "SS") 

747 ) 

748 

749 

750async def run( 

751 program: str, 

752 *args: str, 

753 stdin: int | IO[Any] = asyncio.subprocess.DEVNULL, 

754 stdout: None | int | IO[Any] = asyncio.subprocess.PIPE, 

755 stderr: None | int | IO[Any] = asyncio.subprocess.PIPE, 

756 **kwargs: Any, 

757) -> tuple[None | int, bytes, bytes]: 

758 """Run a programm and return the exit code, stdout and stderr as tuple.""" 

759 proc = await asyncio.create_subprocess_exec( 

760 program, 

761 *args, 

762 stdin=stdin, 

763 stdout=stdout, 

764 stderr=stderr, 

765 **kwargs, 

766 ) 

767 output = await proc.communicate() 

768 return proc.returncode, *output 

769 

770 

771def size_of_file(file: Traversable) -> int: 

772 """Calculate the size of a file.""" 

773 if isinstance(file, Path): 

774 return file.stat().st_size 

775 

776 with file.open("rb") as data: 

777 return sum(map(len, data)) # pylint: disable=bad-builtin 

778 

779 

780def str_to_bool(val: None | str | bool, default: None | bool = None) -> bool: 

781 """Convert a string representation of truth to True or False.""" 

782 if isinstance(val, bool): 

783 return val 

784 if isinstance(val, str): 

785 val = val.lower() 

786 if val in { 

787 "1", 

788 "a", 

789 "accept", 

790 "e", 

791 "enabled", 

792 "on", 

793 "s", 

794 "sure", 

795 "t", 

796 "true", 

797 "y", 

798 "yes", 

799 }: 

800 return True 

801 if val in { 

802 "0", 

803 "d", 

804 "disabled", 

805 "f", 

806 "false", 

807 "n", 

808 "no", 

809 "nope", 

810 "off", 

811 "r", 

812 "reject", 

813 }: 

814 return False 

815 if val in {"idc", "maybe", "random"}: 

816 return bool(random.randrange(2)) # nosec: B311 

817 if default is None: 

818 raise ValueError(f"Invalid bool value: {val!r}") 

819 return default 

820 

821 

822def str_to_set(string: str) -> set[str]: 

823 """Convert a string to a set of strings.""" 

824 return {part.strip() for part in string.split(",") if part.strip()} 

825 

826 

827def strangle(string: str) -> float: 

828 """Convert a string to an angle.""" 

829 hasher = sha1(string.encode("UTF-8"), usedforsecurity=False) 

830 return int.from_bytes(hasher.digest()[:2], "little") / (1 << 16) * 360 

831 

832 

833def time_function[ # noqa: D103 

834 # pylint: disable-next=invalid-name 

835 T, **P # fmt: skip 

836](function: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> tuple[ 

837 T, float 

838]: 

839 """Run the function and return the result and the time it took in seconds.""" 

840 timer = Timer() 

841 return function(*args, **kwargs), timer.stop() 

842 

843 

844def time_to_str(spam: float) -> str: 

845 """Convert the time into a string with second precision.""" 

846 int_time = int(spam) 

847 div_60 = int(int_time / 60) 

848 div_60_60 = int(div_60 / 60) 

849 

850 return ( 

851 f"{int(div_60_60 / 24)}d " 

852 f"{div_60_60 % 24}h " 

853 f"{div_60 % 60}min " 

854 f"{int_time % 60}s" 

855 ) 

856 

857 

858@dataclass(order=True, frozen=True, slots=True) 

859class PageInfo: 

860 """The PageInfo class that is used for the subpages of a ModuleInfo.""" 

861 

862 name: str 

863 description: str 

864 path: None | str = None 

865 # keywords that can be used for searching 

866 keywords: tuple[str, ...] = field(default_factory=tuple) 

867 hidden: bool = False # whether to hide this page info on the page 

868 short_name: None | str = None # short name for the page 

869 

870 

871@dataclass(order=True, frozen=True, slots=True) 

872class ModuleInfo(PageInfo): 

873 """ 

874 The ModuleInfo class adds handlers and subpages to the PageInfo. 

875 

876 This gets created by every module to add the handlers. 

877 """ 

878 

879 handlers: tuple[Handler, ...] = field(default_factory=tuple[Handler, ...]) 

880 sub_pages: tuple[PageInfo, ...] = field(default_factory=tuple) 

881 aliases: tuple[str, ...] | Mapping[str, str] = field(default_factory=tuple) 

882 required_background_tasks: Collection[BackgroundTask] = field( 

883 default_factory=frozenset 

884 ) 

885 

886 def get_keywords_as_str(self, path: str) -> str: 

887 """Get the keywords as comma-seperated string.""" 

888 page_info = self.get_page_info(path) 

889 if self != page_info: 

890 return ", ".join((*self.keywords, *page_info.keywords)) 

891 

892 return ", ".join(self.keywords) 

893 

894 def get_page_info(self, path: str) -> PageInfo: 

895 """Get the PageInfo of the specified path.""" 

896 if self.path == path: 

897 return self 

898 

899 for page_info in self.sub_pages: 

900 if page_info.path == path: 

901 return page_info 

902 

903 return self