Coverage for slidge/util/util.py: 81%
179 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-09 03:59 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-09 03:59 +0000
1import logging
2import mimetypes
3import re
4from collections.abc import Callable, Collection, Coroutine
5from functools import wraps
6from pathlib import Path
7from time import time
8from typing import (
9 Any,
10 ClassVar,
11 Concatenate,
12 NamedTuple,
13 ParamSpec,
14 Protocol,
15 TypeVar,
16)
17from xml.etree import ElementTree as ET
19try:
20 import emoji
21except ImportError:
22 EMOJI_LIB_AVAILABLE = False
23else:
24 EMOJI_LIB_AVAILABLE = True
26from slixmpp.types import ExtPresenceShows, ResourceDict
28from .types import Mention
30try:
31 import magic
32except ImportError as e:
33 magic = None # type:ignore
34 logging.warning(
35 (
36 "Libmagic is not available: %s. "
37 "It's OK if you don't use fix-filename-suffix-mime-type."
38 ),
39 e,
40 )
43def fix_suffix(
44 path: Path, mime_type: str | None, file_name: str | None
45) -> tuple[str, str]:
46 guessed = magic.from_file(path, mime=True)
47 if guessed == mime_type:
48 log.debug("Magic and given MIME match")
49 else:
50 log.debug("Magic (%s) and given MIME (%s) differ", guessed, mime_type)
51 mime_type = guessed
53 valid_suffix_list = mimetypes.guess_all_extensions(mime_type, strict=False)
55 name = Path(file_name) if file_name else Path(path.name)
57 suffix = name.suffix
59 if suffix in valid_suffix_list:
60 log.debug("Suffix %s is in %s", suffix, valid_suffix_list)
61 return str(name), guessed
63 valid_suffix = mimetypes.guess_extension(mime_type.split(";")[0], strict=False)
64 if valid_suffix is None:
65 log.debug("No valid suffix found")
66 return str(name), guessed
68 log.debug("Changing suffix of %s to %s", file_name or path.name, valid_suffix)
69 return str(name.with_suffix(valid_suffix)), guessed
72class SubclassableOnce:
73 # To allow importing everything, including plugins, during tests
74 TEST_MODE: bool = False
75 __subclasses: ClassVar[
76 dict[type["SubclassableOnce"], type["SubclassableOnce"] | None]
77 ] = {}
79 def __init_subclass__(cls, **kwargs: object) -> None:
80 if SubclassableOnce not in cls.__bases__:
81 base = SubclassableOnce.__find_direct_child(cls)
82 existing = SubclassableOnce.__subclasses.get(base)
83 if existing is not None and not SubclassableOnce.TEST_MODE:
84 raise RuntimeError("This class must be subclassed once at most!")
85 cls.__subclasses[base] = cls
86 super().__init_subclass__(**kwargs)
88 @staticmethod
89 def __find_direct_child(cls: type["SubclassableOnce"]) -> type["SubclassableOnce"]:
90 for base in cls.__bases__:
91 if issubclass(base, SubclassableOnce):
92 return base
93 else:
94 raise RuntimeError("wut")
96 @classmethod
97 def get_self_or_unique_subclass(cls) -> "type[SubclassableOnce]":
98 try:
99 return cls.get_unique_subclass()
100 except AttributeError:
101 return cls
103 @classmethod
104 def get_unique_subclass(cls) -> "type[SubclassableOnce]":
105 existing = SubclassableOnce.__subclasses.get(cls)
106 if existing is None:
107 raise AttributeError("Could not find any subclass", cls)
108 return existing
110 @classmethod
111 def reset_subclass(cls) -> None:
112 log.debug("Resetting subclass of %s", cls)
113 cls.__subclasses[cls] = None
116def is_valid_phone_number(phone: str | None) -> bool:
117 if phone is None:
118 return False
119 match = re.match(r"\+\d.*", phone)
120 if match is None:
121 return False
122 return match[0] == phone
125def strip_illegal_chars(s: str, repl: str = "") -> str:
126 return ILLEGAL_XML_CHARS_RE.sub(repl, s)
129# from https://stackoverflow.com/a/64570125/5902284 and Link Mauve
130ILLEGAL = [
131 (0x00, 0x08),
132 (0x0B, 0x0C),
133 (0x0E, 0x1F),
134 (0x7F, 0x84),
135 (0x86, 0x9F),
136 (0xFDD0, 0xFDDF),
137 (0xFFFE, 0xFFFF),
138 (0x1FFFE, 0x1FFFF),
139 (0x2FFFE, 0x2FFFF),
140 (0x3FFFE, 0x3FFFF),
141 (0x4FFFE, 0x4FFFF),
142 (0x5FFFE, 0x5FFFF),
143 (0x6FFFE, 0x6FFFF),
144 (0x7FFFE, 0x7FFFF),
145 (0x8FFFE, 0x8FFFF),
146 (0x9FFFE, 0x9FFFF),
147 (0xAFFFE, 0xAFFFF),
148 (0xBFFFE, 0xBFFFF),
149 (0xCFFFE, 0xCFFFF),
150 (0xDFFFE, 0xDFFFF),
151 (0xEFFFE, 0xEFFFF),
152 (0xFFFFE, 0xFFFFF),
153 (0x10FFFE, 0x10FFFF),
154]
156ILLEGAL_RANGES = [rf"{chr(low)}-{chr(high)}" for (low, high) in ILLEGAL]
157XML_ILLEGAL_CHARACTER_REGEX = "[" + "".join(ILLEGAL_RANGES) + "]"
158ILLEGAL_XML_CHARS_RE = re.compile(XML_ILLEGAL_CHARACTER_REGEX)
161# from https://stackoverflow.com/a/35804945/5902284
162def addLoggingLevel(
163 levelName: str = "TRACE",
164 levelNum: int = logging.DEBUG - 5,
165 methodName: str | None = None,
166) -> None:
167 """
168 Comprehensively adds a new logging level to the `logging` module and the
169 currently configured logging class.
171 `levelName` becomes an attribute of the `logging` module with the value
172 `levelNum`. `methodName` becomes a convenience method for both `logging`
173 itself and the class returned by `logging.getLoggerClass()` (usually just
174 `logging.Logger`). If `methodName` is not specified, `levelName.lower()` is
175 used.
177 To avoid accidental clobberings of existing attributes, this method will
178 raise an `AttributeError` if the level name is already an attribute of the
179 `logging` module or if the method name is already present
181 Example
182 -------
183 >>> addLoggingLevel('TRACE', logging.DEBUG - 5)
184 >>> logging.getLogger(__name__).setLevel("TRACE")
185 >>> logging.getLogger(__name__).trace('that worked')
186 >>> logging.trace('so did this')
187 >>> logging.TRACE
188 5
190 """
191 if not methodName:
192 methodName = levelName.lower()
194 if hasattr(logging, levelName):
195 log.debug(f"{levelName} already defined in logging module")
196 return
197 if hasattr(logging, methodName):
198 log.debug(f"{methodName} already defined in logging module")
199 return
200 if hasattr(logging.getLoggerClass(), methodName):
201 log.debug(f"{methodName} already defined in logger class")
202 return
204 # This method was inspired by the answers to Stack Overflow post
205 # http://stackoverflow.com/q/2183233/2988730, especially
206 # http://stackoverflow.com/a/13638084/2988730
207 def logForLevel(self, message, *args, **kwargs) -> None: # type:ignore[no-untyped-def] # noqa
208 if self.isEnabledFor(levelNum):
209 self._log(levelNum, message, args, **kwargs)
211 def logToRoot(message, *args, **kwargs) -> None: # type:ignore[no-untyped-def] # noqa
212 logging.log(levelNum, message, *args, **kwargs)
214 logging.addLevelName(levelNum, levelName)
215 setattr(logging, levelName, levelNum)
216 setattr(logging.getLoggerClass(), methodName, logForLevel)
217 setattr(logging, methodName, logToRoot)
220class SlidgeLogger(logging.Logger):
221 def trace(self) -> None:
222 pass
225log = logging.getLogger(__name__)
228def merge_resources(resources: dict[str, ResourceDict]) -> ResourceDict | None:
229 if len(resources) == 0:
230 return None
232 if len(resources) == 1:
233 return next(iter(resources.values()))
235 by_priority = sorted(resources.values(), key=lambda r: r["priority"], reverse=True)
237 if any(r["show"] == "" for r in resources.values()):
238 # if a client is "available", we're "available"
239 show: ExtPresenceShows = ""
240 else:
241 for r in by_priority:
242 if r["show"]:
243 show = r["show"]
244 break
245 else:
246 raise RuntimeError()
248 # if there are different statuses, we use the highest priority one,
249 # but we ignore resources without status, even with high priority
250 status = ""
251 for r in by_priority:
252 if r["status"]:
253 status = r["status"]
254 break
256 return {
257 "show": show,
258 "status": status,
259 "priority": 0,
260 }
263_EMOJI_VARIATION_SELECTOR = "\ufe0f"
266def remove_emoji_variation_selector_16(emoji: str) -> str:
267 # this is required for compatibility with dino, and maybe other future clients?
268 return emoji.removesuffix(_EMOJI_VARIATION_SELECTOR)
271NamedTupleT = TypeVar("NamedTupleT", bound=NamedTuple)
274def dict_to_named_tuple(data: dict[str, Any], cls: type[NamedTupleT]) -> NamedTupleT:
275 return cls(*(data.get(f) for f in cls._fields)) # type:ignore[arg-type]
278def replace_mentions(
279 text: str,
280 mentions: Collection[Mention] | None,
281 mapping: Callable[[Mention], str],
282) -> str:
283 if not mentions:
284 return text
286 cursor = 0
287 pieces = []
288 for mention in mentions:
289 try:
290 new_text = mapping(mention)
291 except Exception as exc:
292 log.debug("Attempting slidge <= 0.3.3 compatibility: %s", exc)
293 new_text = mapping(mention.contact) # type:ignore
294 pieces.extend([text[cursor : mention.start], new_text])
295 cursor = mention.end
296 pieces.append(text[cursor:])
297 return "".join(pieces)
300class HasLogger(Protocol):
301 log: logging.Logger
304P = ParamSpec("P")
305T = TypeVar("T")
306Self = TypeVar("Self", bound=HasLogger)
307TimeItWrapped = Callable[Concatenate[Self, P], Coroutine[Any, Any, T]]
310def timeit(func: TimeItWrapped[Self, P, T]) -> TimeItWrapped[Self, P, T]:
311 @wraps(func)
312 async def wrapped(self: Self, /, *args: P.args, **kwargs: P.kwargs) -> T:
313 start = time()
314 r = await func(self, *args, **kwargs)
315 self.log.debug("%s took %s ms", func.__name__, round((time() - start) * 1000))
316 return r
318 return wrapped
321def strip_leading_emoji(text: str) -> str:
322 if not EMOJI_LIB_AVAILABLE:
323 return text
324 words = text.split(" ")
325 # is_emoji returns False for 🛷️ for obscure reasons,
326 # purely_emoji seems better
327 if len(words) > 1 and emoji.purely_emoji(words[0]):
328 return " ".join(words[1:])
329 return text
332async def noop_coro() -> None:
333 pass
336def add_quote_prefix(text: str) -> str:
337 """
338 Return multi-line text with leading quote marks (i.e. the ">" character).
339 """
340 return "\n".join(("> " + x).strip() for x in text.split("\n")).strip()
343def fix_namespaces(
344 xml: ET.Element,
345 old: str,
346 new: str,
347) -> None:
348 """
349 Hack to fix namespaces between jabber:component and jabber:client
351 Acts in-place.
353 :param xml:
354 :param old:
355 :param new:
356 """
357 xml.tag = xml.tag.replace(f"{{{old}}}", f"{{{new}}}")
358 for child in xml:
359 fix_namespaces(child, old, new)