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
« 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/>.
14"""A module with many useful things used by other modules."""
16from __future__ import annotations
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
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]
71from .. import DIR as ROOT_DIR
73if TYPE_CHECKING:
74 from .background_tasks import BackgroundTask
76LOGGER: Final = logging.getLogger(__name__)
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]
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)
91PRINT = int.from_bytes((ROOT_DIR / "primes.bin").read_bytes(), "big")
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}
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}
129class ArgparseNamespace(argparse.Namespace):
130 """A class to fake type hints for argparse.Namespace."""
132 # pylint: disable=too-few-public-methods
133 __slots__ = ("config", "save_config_to")
135 config: list[pathlib.Path]
136 save_config_to: pathlib.Path | None
139class AwaitableValue[T](Awaitable[T]):
140 # pylint: disable=too-few-public-methods
141 """An awaitable that always returns the same value."""
143 def __await__(self) -> Generator[None, None, T]:
144 """Return the value."""
145 yield
146 return self._value
148 def __init__(self, value: T) -> None:
149 """Set the value."""
150 self._value = value
153class Permission(IntFlag):
154 """Permissions for accessing restricted stuff."""
156 RATELIMITS = 1
157 TRACEBACK = 2
158 BACKDOOR = 4
159 UPDATE = 8
160 REPORTING = 16
161 SHORTEN = 32
162 UPLOAD = 64
165class Timer:
166 """Timer class used for timing stuff."""
168 __slots__ = ("_execution_time", "_start_time")
170 _execution_time: int
172 def __init__(self) -> None:
173 """Start the timer."""
174 self._start_time = time.perf_counter_ns()
176 def get(self) -> float:
177 """Get the execution time in seconds."""
178 return self.get_ns() / 1_000_000_000
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
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
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
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)
202 if not kwargs:
203 return url.geturl()
205 url_args: dict[str, str] = dict(
206 parse_qsl(url.query, keep_blank_values=True)
207 )
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)
219 return urlunsplit(
220 (
221 url.scheme,
222 url.netloc,
223 url.path,
224 urlencode(url_args),
225 url.fragment,
226 )
227 )
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
237 address = address.strip()
239 try:
240 version = ip_address(address).version
241 except ValueError:
242 if ignore_invalid:
243 return address
244 raise
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)
251 raise HTTPError(reason="ERROR: -41")
254ansi_replace = partial(regex.sub, "\033" + r"\[-?\d+[a-zA-Z]", "")
255ansi_replace.__doc__ = "Remove ANSI escape sequences from a string."
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
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)
294backspace_replace = partial(regex.sub, ".?\x08", "")
295backspace_replace.__doc__ = "Remove backspaces from a string."
298def bool_to_str(val: bool) -> str:
299 """Convert a boolean to sure/nope."""
300 return "sure" if val else "nope"
303def bounded_edit_distance(s1: str, s2: str, /, k: int) -> int:
304 """Return a bounded edit distance between two strings.
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
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())
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
341def emoji2html(emoji: str) -> str:
342 """Convert an emoji to HTML."""
343 return f"<img src={emoji2url(emoji)!r} alt={emoji!r} class='emoji'>"
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}"
354EMOJI_MAPPING: Final[Mapping[str, str]] = {
355 "⁉": "⁉",
356 "‼": "‼",
357 "?": "❓",
358 "!": "❗",
359 "-": "➖",
360 "+": "➕",
361 "\U0001F51F": "\U0001F51F",
362}
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)
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
390 if non_emojis:
391 yield "".join(non_emojis)
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
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
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
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
471 if "country_iso_code" in cache[database]:
472 cache[database]["country_flag"] = country_code_to_flag(
473 cache[database]["country_iso_code"]
474 )
476 caches[ip] = cache
477 return cache[database]
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
485 info_dict = info.get_info_dict()
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")
491 data = {
492 "continent_name": continent_name,
493 "country_iso_code": country_iso_code,
494 "country_name": country_name,
495 }
497 if data["country_iso_code"]:
498 data["country_flag"] = country_code_to_flag(data["country_iso_code"])
500 if country:
501 for key, value in tuple(data.items()):
502 if not value:
503 del data[key]
505 return data
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")
512 data.update({"location": location, "timezone": time_zone})
514 for key, value in tuple(data.items()):
515 if not value:
516 del data[key]
518 return data
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"})
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.
534 word is a sequence for which close matches are desired (typically a string).
536 possibilities is a list of sequences against which to match word
537 (typically a list of strings).
539 Optional arg count (default 3) is the maximum number of close matches to
540 return. count must be > 0.
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.
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)
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")
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 )
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
609 return cast(bool, info.get_info_dict().get("is_in_european_union", False))
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)))
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()
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
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("-")
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
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)
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
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}"
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 )
701 now = time.time()
703 headers: dict[str, str] = {}
705 if result[0]:
706 headers["Retry-After"] = str(result[3])
707 if not bucket:
708 headers["X-RateLimit-Global"] = "true"
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"))
717 return bool(result[0]), headers
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
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 )
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
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
776 with file.open("rb") as data:
777 return sum(map(len, data)) # pylint: disable=bad-builtin
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
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()}
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
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()
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)
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 )
858@dataclass(order=True, frozen=True, slots=True)
859class PageInfo:
860 """The PageInfo class that is used for the subpages of a ModuleInfo."""
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
871@dataclass(order=True, frozen=True, slots=True)
872class ModuleInfo(PageInfo):
873 """
874 The ModuleInfo class adds handlers and subpages to the PageInfo.
876 This gets created by every module to add the handlers.
877 """
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 )
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))
892 return ", ".join(self.keywords)
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
899 for page_info in self.sub_pages:
900 if page_info.path == path:
901 return page_info
903 return self