Coverage for slidge/util/util.py: 75%
186 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-10 12:26 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-10 12:26 +0000
1import logging
2import mimetypes
3import re
4import uuid
5from collections.abc import Awaitable, Callable, Collection, Coroutine
6from contextlib import AbstractContextManager
7from functools import wraps
8from inspect import iscoroutinefunction
9from pathlib import Path
10from time import time
11from typing import (
12 Any,
13 Concatenate,
14 NamedTuple,
15 ParamSpec,
16 Protocol,
17 TypeVar,
18 get_args,
19 get_origin,
20 overload,
21)
22from xml.etree import ElementTree as ET
24try:
25 import emoji
26except ImportError:
27 EMOJI_LIB_AVAILABLE = False
28else:
29 EMOJI_LIB_AVAILABLE = True
31from slixmpp.types import ExtPresenceShows, ResourceDict
33from .types import AnyParticipant, Mention
35try:
36 import magic
37except ImportError as e:
38 magic = None # type:ignore
39 logging.warning( # noqa: LOG015
40 (
41 "Libmagic is not available: %s. "
42 "It's OK if you don't use fix-filename-suffix-mime-type."
43 ),
44 e,
45 )
48def fix_suffix(
49 path: Path, mime_type: str | None, file_name: str | None
50) -> tuple[str, str]:
51 guessed = magic.from_file(path, mime=True)
52 if guessed == mime_type:
53 log.debug("Magic and given MIME match")
54 else:
55 log.debug("Magic (%s) and given MIME (%s) differ", guessed, mime_type)
56 mime_type = guessed
58 valid_suffix_list = mimetypes.guess_all_extensions(mime_type, strict=False)
60 name = Path(file_name) if file_name else Path(path.name)
62 suffix = name.suffix
64 if suffix in valid_suffix_list:
65 log.debug("Suffix %s is in %s", suffix, valid_suffix_list)
66 return str(name), guessed
68 valid_suffix = mimetypes.guess_extension(mime_type.split(";")[0], strict=False)
69 if valid_suffix is None:
70 log.debug("No valid suffix found")
71 return str(name), guessed
73 log.debug("Changing suffix of %s to %s", file_name or path.name, valid_suffix)
74 return str(name.with_suffix(valid_suffix)), guessed
77def is_valid_phone_number(phone: str | None) -> bool:
78 if phone is None:
79 return False
80 match = re.match(r"\+\d.*", phone)
81 if match is None:
82 return False
83 return match[0] == phone
86def strip_illegal_chars(s: str, repl: str = "") -> str:
87 return ILLEGAL_XML_CHARS_RE.sub(repl, s)
90# from https://stackoverflow.com/a/64570125/5902284 and Link Mauve
91ILLEGAL = [
92 (0x00, 0x08),
93 (0x0B, 0x0C),
94 (0x0E, 0x1F),
95 (0x7F, 0x84),
96 (0x86, 0x9F),
97 (0xFDD0, 0xFDDF),
98 (0xFFFE, 0xFFFF),
99 (0x1FFFE, 0x1FFFF),
100 (0x2FFFE, 0x2FFFF),
101 (0x3FFFE, 0x3FFFF),
102 (0x4FFFE, 0x4FFFF),
103 (0x5FFFE, 0x5FFFF),
104 (0x6FFFE, 0x6FFFF),
105 (0x7FFFE, 0x7FFFF),
106 (0x8FFFE, 0x8FFFF),
107 (0x9FFFE, 0x9FFFF),
108 (0xAFFFE, 0xAFFFF),
109 (0xBFFFE, 0xBFFFF),
110 (0xCFFFE, 0xCFFFF),
111 (0xDFFFE, 0xDFFFF),
112 (0xEFFFE, 0xEFFFF),
113 (0xFFFFE, 0xFFFFF),
114 (0x10FFFE, 0x10FFFF),
115]
117ILLEGAL_RANGES = [rf"{chr(low)}-{chr(high)}" for (low, high) in ILLEGAL]
118XML_ILLEGAL_CHARACTER_REGEX = "[" + "".join(ILLEGAL_RANGES) + "]"
119ILLEGAL_XML_CHARS_RE = re.compile(XML_ILLEGAL_CHARACTER_REGEX)
122# from https://stackoverflow.com/a/35804945/5902284
123def addLoggingLevel(
124 levelName: str = "TRACE",
125 levelNum: int = logging.DEBUG - 5,
126 methodName: str | None = None,
127) -> None:
128 """
129 Comprehensively adds a new logging level to the `logging` module and the
130 currently configured logging class.
132 `levelName` becomes an attribute of the `logging` module with the value
133 `levelNum`. `methodName` becomes a convenience method for both `logging`
134 itself and the class returned by `logging.getLoggerClass()` (usually just
135 `logging.Logger`). If `methodName` is not specified, `levelName.lower()` is
136 used.
138 To avoid accidental clobberings of existing attributes, this method will
139 raise an `AttributeError` if the level name is already an attribute of the
140 `logging` module or if the method name is already present
142 Example
143 -------
144 >>> addLoggingLevel('TRACE', logging.DEBUG - 5)
145 >>> logging.getLogger(__name__).setLevel("TRACE")
146 >>> logging.getLogger(__name__).trace('that worked')
147 >>> logging.trace('so did this')
148 >>> logging.TRACE
149 5
151 """
152 if not methodName:
153 methodName = levelName.lower()
155 if hasattr(logging, levelName):
156 log.debug(f"{levelName} already defined in logging module")
157 return
158 if hasattr(logging, methodName):
159 log.debug(f"{methodName} already defined in logging module")
160 return
161 if hasattr(logging.getLoggerClass(), methodName):
162 log.debug(f"{methodName} already defined in logger class")
163 return
165 # This method was inspired by the answers to Stack Overflow post
166 # http://stackoverflow.com/q/2183233/2988730, especially
167 # http://stackoverflow.com/a/13638084/2988730
168 def logForLevel(self, message, *args, **kwargs) -> None: # type:ignore[no-untyped-def] # noqa
169 if self.isEnabledFor(levelNum):
170 self._log(levelNum, message, args, **kwargs)
172 def logToRoot(message, *args, **kwargs) -> None: # type:ignore[no-untyped-def] # noqa
173 logging.log(levelNum, message, *args, **kwargs) # noqa: LOG015
175 logging.addLevelName(levelNum, levelName)
176 setattr(logging, levelName, levelNum)
177 setattr(logging.getLoggerClass(), methodName, logForLevel)
178 setattr(logging, methodName, logToRoot)
181class SlidgeLogger(logging.Logger):
182 def trace(self) -> None:
183 pass
186log = logging.getLogger(__name__)
189def merge_resources(resources: dict[str, ResourceDict]) -> ResourceDict | None:
190 if len(resources) == 0:
191 return None
193 if len(resources) == 1:
194 return next(iter(resources.values()))
196 by_priority = sorted(resources.values(), key=lambda r: r["priority"], reverse=True)
198 if any(r["show"] == "" for r in resources.values()):
199 # if a client is "available", we're "available"
200 show: ExtPresenceShows = ""
201 else:
202 for r in by_priority:
203 if r["show"]:
204 show = r["show"]
205 break
206 else:
207 raise RuntimeError()
209 # if there are different statuses, we use the highest priority one,
210 # but we ignore resources without status, even with high priority
211 status = ""
212 for r in by_priority:
213 if r["status"]:
214 status = r["status"]
215 break
217 return {
218 "show": show,
219 "status": status,
220 "priority": 0,
221 }
224_EMOJI_VARIATION_SELECTOR = "\ufe0f"
227def remove_emoji_variation_selector_16(emoji: str) -> str:
228 # this is required for compatibility with dino, and maybe other future clients?
229 return emoji.rstrip(_EMOJI_VARIATION_SELECTOR)
232NamedTupleT = TypeVar("NamedTupleT", bound=NamedTuple)
235def dict_to_named_tuple[NamedTupleT: NamedTuple](
236 data: dict[str, Any], cls: type[NamedTupleT]
237) -> NamedTupleT:
238 return cls(*(data.get(f) for f in cls._fields)) # type:ignore[arg-type]
241def replace_mentions[T: AnyParticipant](
242 text: str,
243 mentions: Collection[Mention[T]] | None,
244 mapping: Callable[[Mention[T]], str],
245) -> str:
246 if not mentions:
247 return text
249 cursor = 0
250 pieces = []
251 for mention in mentions:
252 new_text = mapping(mention)
253 pieces.extend([text[cursor : mention.start], new_text])
254 cursor = mention.end
255 pieces.append(text[cursor:])
256 return "".join(pieces)
259class HasLogger(Protocol):
260 log: logging.Logger
263P = ParamSpec("P")
264T = TypeVar("T")
265Self = TypeVar("Self", bound=HasLogger)
266TimeItWrapped = Callable[Concatenate[Self, P], Coroutine[Any, Any, T]]
269def derive_wired_class(cls: type[Any], origin: type[Any], *attrs: str) -> None:
270 """Derive a factory class's associated type(s) from its generic parameter(s).
272 E.g.: for ``class Roster(LegacyRoster[Contact])``, set ``Roster.contact_cls`` to
273 ``Contact``. Attribute names are passed in the same order as the generic
274 parameters.
275 """
276 for attr in attrs:
277 if attr in cls.__dict__:
278 raise TypeError(
279 f"{cls.__name__} must not declare '{attr}' explicitly: it is"
280 " derived from the generic parameter.",
281 )
282 for base in getattr(cls, "__orig_bases__", ()):
283 if get_origin(base) is not origin:
284 continue
285 for attr, arg in zip(attrs, get_args(base)):
286 if isinstance(arg, TypeVar) or type(arg).__name__ == "TypeVar":
287 # Still generic? Intermediate subclass? Probably?
288 continue
289 if not isinstance(arg, type):
290 # e.g. LegacyBookmarks[LegacyRoom[LegacyParticipant]]: the runtime
291 # class to instantiate is the alias' origin, LegacyRoom
292 arg = get_origin(arg)
293 if isinstance(arg, type):
294 setattr(cls, attr, arg)
295 continue
296 raise TypeError(
297 f"Cannot derive {cls.__name__}.{attr} from a forward reference. "
298 f"Parameterize {origin.__name__} with the actual class."
299 )
300 return
303def timeit[Self: HasLogger, **P, T](
304 func: TimeItWrapped[Self, P, T],
305) -> TimeItWrapped[Self, P, T]:
306 @wraps(func)
307 async def wrapped(self: Self, /, *args: P.args, **kwargs: P.kwargs) -> T:
308 start = time()
309 r = await func(self, *args, **kwargs)
310 self.log.debug("%s took %s ms", func.__name__, round((time() - start) * 1000))
311 return r
313 return wrapped
316def strip_leading_emoji(text: str) -> str:
317 if not EMOJI_LIB_AVAILABLE:
318 return text
319 words = text.split(" ")
320 # is_emoji returns False for 🛷️ for obscure reasons,
321 # purely_emoji seems better
322 if len(words) > 1 and emoji.purely_emoji(words[0]):
323 return " ".join(words[1:])
324 return text
327async def noop_coro() -> None:
328 pass
331def add_quote_prefix(text: str) -> str:
332 """
333 Return multi-line text with leading quote marks (i.e. the ">" character).
334 """
335 return "\n".join(("> " + x).strip() for x in text.split("\n")).strip()
338def fix_namespaces(
339 xml: ET.Element,
340 old: str,
341 new: str,
342) -> None:
343 """
344 Hack to fix namespaces between jabber:component and jabber:client
346 Acts in-place.
348 :param xml:
349 :param old:
350 :param new:
351 """
352 xml.tag = xml.tag.replace(f"{{{old}}}", f"{{{new}}}")
353 for child in xml:
354 fix_namespaces(child, old, new)
357class _HasBatchSetAttributes(Protocol):
358 def batch_set_attributes(self) -> AbstractContextManager[None]: ...
361@overload
362def batch_set_attributes[S: _HasBatchSetAttributes, **P, R](
363 func: Callable[Concatenate[S, P], Awaitable[R]],
364) -> Callable[Concatenate[S, P], Awaitable[R]]: ...
367@overload
368def batch_set_attributes[S: _HasBatchSetAttributes, **P, R](
369 func: Callable[Concatenate[S, P], R],
370) -> Callable[Concatenate[S, P], R]: ...
373def batch_set_attributes(func: Callable[..., Any]) -> Callable[..., Any]:
374 if iscoroutinefunction(func):
376 @wraps(func)
377 async def wrapper(
378 self: _HasBatchSetAttributes,
379 *args: Any, # noqa: ANN401
380 **kwargs: Any, # noqa: ANN401
381 ) -> Any: # noqa: ANN401
382 with self.batch_set_attributes():
383 return await func(self, *args, **kwargs)
385 return wrapper
387 else:
389 @wraps(func)
390 def wrapper(self: _HasBatchSetAttributes, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401
391 with self.batch_set_attributes():
392 return func(self, *args, **kwargs)
394 return wrapper
397def uniquify_replace(legacy_msg_id: str) -> str:
398 return f"--slidge--correct--{uuid.uuid4()}--{legacy_msg_id}"
401def recover_replaced_legacy_msg_id(xmpp_id: str) -> str:
402 if not xmpp_id.startswith("--slidge--correct--"):
403 return xmpp_id
404 return xmpp_id.removeprefix("--slidge--correct--")[_uuid4_len + 2 :]
407_uuid4_len = 36