slidge 0.3.1__py3-none-any.whl → 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
slidge/contact/contact.py CHANGED
@@ -411,7 +411,7 @@ class LegacyContact(
411
411
  # we only broadcast pubsub events for contacts added to the roster
412
412
  # so if something was set before, we need to push it now
413
413
  self.added_to_roster = True
414
- self.send_last_presence()
414
+ self.send_last_presence(force=True)
415
415
 
416
416
  async def __broadcast_pubsub_items(self) -> None:
417
417
  if not self.is_friend:
slidge/contact/roster.py CHANGED
@@ -4,7 +4,7 @@ import warnings
4
4
  from typing import TYPE_CHECKING, AsyncIterator, Generic, Iterator, Optional, Type
5
5
 
6
6
  from slixmpp import JID
7
- from slixmpp.exceptions import IqError, IqTimeout
7
+ from slixmpp.exceptions import IqError, IqTimeout, XMPPError
8
8
  from sqlalchemy.orm import Session
9
9
  from sqlalchemy.orm import Session as OrmSession
10
10
 
@@ -92,6 +92,10 @@ class LegacyRoster(
92
92
  # :return:
93
93
  # """
94
94
  username = contact_jid.node
95
+ if not username:
96
+ raise XMPPError(
97
+ "bad-request", "Contacts must have a local part in their JID"
98
+ )
95
99
  contact_jid = JID(contact_jid.bare)
96
100
  async with self.lock(("username", username)):
97
101
  legacy_id = await self.jid_username_to_legacy_id(username)
@@ -239,6 +243,7 @@ class LegacyRoster(
239
243
  warnings.warn(f"Could not add to roster: {e}")
240
244
  else:
241
245
  contact.added_to_roster = True
246
+ contact.send_last_presence(force=True)
242
247
  orm.commit()
243
248
  self.__filling = False
244
249
 
slidge/core/config.py CHANGED
@@ -1,22 +1,26 @@
1
- from datetime import timedelta
2
1
  from pathlib import Path
3
- from typing import Optional, Self
2
+ from typing import Optional
4
3
 
5
4
  from slixmpp import JID as JIDType
6
5
 
6
+ # REQUIRED, so not default value
7
7
 
8
- class _TimedeltaSeconds(timedelta):
9
- def __new__(cls, s: str) -> Self:
10
- return super().__new__(cls, seconds=int(s))
11
8
 
9
+ class _Categories:
10
+ MANDATORY = (0, "Mandatory settings")
11
+ BASE = (10, "Basic configuration")
12
+ ATTACHMENTS = (20, "Attachments")
13
+ LOG = (30, "Logging")
14
+ ADVANCED = (40, "Advanced settings")
12
15
 
13
- # REQUIRED, so not default value
14
16
 
15
17
  LEGACY_MODULE: str
16
18
  LEGACY_MODULE__DOC = (
17
- "Importable python module containing (at least) "
18
- "a BaseGateway and a LegacySession subclass"
19
+ "Importable python module containing (at least) a BaseGateway and a LegacySession subclass. "
20
+ "NB: this is not needed if you use a gateway-specific entrypoint, e.g., `slidgram` or "
21
+ "`python -m slidgram`."
19
22
  )
23
+ LEGACY_MODULE__CATEGORY = _Categories.BASE
20
24
 
21
25
  SERVER: str = "localhost"
22
26
  SERVER__DOC = (
@@ -27,17 +31,21 @@ SERVER__DOC = (
27
31
  "you change this."
28
32
  )
29
33
  SERVER__SHORT = "s"
34
+ SERVER__CATEGORY = _Categories.BASE
30
35
 
31
36
  SECRET: str
32
37
  SECRET__DOC = "The gateway component's secret (required to connect to the XMPP server)"
38
+ SECRET__CATEGORY = _Categories.MANDATORY
33
39
 
34
40
  JID: JIDType
35
41
  JID__DOC = "The gateway component's JID"
36
42
  JID__SHORT = "j"
43
+ JID__CATEGORY = _Categories.MANDATORY
37
44
 
38
45
  PORT: str = "5347"
39
46
  PORT__DOC = "The XMPP server's port for incoming component connections"
40
47
  PORT__SHORT = "p"
48
+ PORT__CATEGORY = _Categories.BASE
41
49
 
42
50
  # Dynamic default (depends on other values)
43
51
 
@@ -47,6 +55,7 @@ HOME_DIR__DOC = (
47
55
  "Defaults to /var/lib/slidge/${SLIDGE_JID}. "
48
56
  )
49
57
  HOME_DIR__DYNAMIC_DEFAULT = True
58
+ HOME_DIR__CATEGORY = _Categories.BASE
50
59
 
51
60
  DB_URL: str
52
61
  DB_URL__DOC = (
@@ -54,6 +63,7 @@ DB_URL__DOC = (
54
63
  "Defaults to sqlite:///${HOME_DIR}/slidge.sqlite"
55
64
  )
56
65
  DB_URL__DYNAMIC_DEFAULT = True
66
+ DB_URL__CATEGORY = _Categories.ADVANCED
57
67
 
58
68
  USER_JID_VALIDATOR: str
59
69
  USER_JID_VALIDATOR__DOC = (
@@ -62,11 +72,13 @@ USER_JID_VALIDATOR__DOC = (
62
72
  "you probably want to change that to .*@example.com"
63
73
  )
64
74
  USER_JID_VALIDATOR__DYNAMIC_DEFAULT = True
75
+ USER_JID_VALIDATOR__CATEGORY = _Categories.BASE
65
76
 
66
77
  # Optional, so default value + type hint if default is None
67
78
 
68
79
  ADMINS: tuple[JIDType, ...] = ()
69
80
  ADMINS__DOC = "JIDs of the gateway admins"
81
+ ADMINS__CATEGORY = _Categories.BASE
70
82
 
71
83
  UPLOAD_SERVICE: Optional[str] = None
72
84
  UPLOAD_SERVICE__DOC = (
@@ -74,11 +86,13 @@ UPLOAD_SERVICE__DOC = (
74
86
  "This is optional, as it should be automatically determined via service"
75
87
  "discovery."
76
88
  )
89
+ UPLOAD_SERVICE__CATEGORY = _Categories.ATTACHMENTS
77
90
 
78
91
  AVATAR_SIZE = 200
79
92
  AVATAR_SIZE__DOC = (
80
93
  "Maximum image size (width and height), image ratio will be preserved"
81
94
  )
95
+ AVATAR_SIZE__CATEGORY = _Categories.ADVANCED
82
96
 
83
97
  USE_ATTACHMENT_ORIGINAL_URLS = False
84
98
  USE_ATTACHMENT_ORIGINAL_URLS__DOC = (
@@ -86,11 +100,13 @@ USE_ATTACHMENT_ORIGINAL_URLS__DOC = (
86
100
  "let XMPP clients directly download them from this URL. Note that this will "
87
101
  "probably leak your client IP to the legacy network."
88
102
  )
103
+ USE_ATTACHMENT_ORIGINAL_URLS__CATEGORY = _Categories.ATTACHMENTS
89
104
 
90
105
  UPLOAD_REQUESTER: Optional[str] = None
91
106
  UPLOAD_REQUESTER__DOC = (
92
107
  "Set which JID should request the upload slots. Defaults to the component JID."
93
108
  )
109
+ UPLOAD_REQUESTER__CATEGORY = _Categories.ATTACHMENTS
94
110
 
95
111
  NO_UPLOAD_PATH: Optional[str] = None
96
112
  NO_UPLOAD_PATH__DOC = (
@@ -98,38 +114,45 @@ NO_UPLOAD_PATH__DOC = (
98
114
  "You need to set NO_UPLOAD_URL_PREFIX too if you use this option, and configure "
99
115
  "an web server to serve files in this dir."
100
116
  )
117
+ NO_UPLOAD_PATH__CATEGORY = _Categories.ATTACHMENTS
101
118
 
102
119
  NO_UPLOAD_URL_PREFIX: Optional[str] = None
103
120
  NO_UPLOAD_URL_PREFIX__DOC = (
104
121
  "Base URL that servers files in the dir set in the no-upload-path option, "
105
122
  "eg https://example.com:666/slidge-attachments/"
106
123
  )
124
+ NO_UPLOAD_URL_PREFIX__CATEGORY = _Categories.ATTACHMENTS
107
125
 
108
126
  NO_UPLOAD_METHOD: str = "copy"
109
127
  NO_UPLOAD_METHOD__DOC = (
110
128
  "Whether to 'copy', 'move', 'hardlink' or 'symlink' the files in no-upload-path."
111
129
  )
130
+ NO_UPLOAD_METHOD__CATEGORY = _Categories.ATTACHMENTS
112
131
 
113
132
  NO_UPLOAD_FILE_READ_OTHERS = False
114
133
  NO_UPLOAD_FILE_READ_OTHERS__DOC = (
115
134
  "After writing a file in NO_UPLOAD_PATH, change its permission so that 'others' can"
116
135
  " read it."
117
136
  )
137
+ NO_UPLOAD_FILE_READ_OTHERS__CATEGORY = _Categories.ATTACHMENTS
118
138
 
119
- IGNORE_DELAY_THRESHOLD = _TimedeltaSeconds("300")
139
+ IGNORE_DELAY_THRESHOLD = 300
120
140
  IGNORE_DELAY_THRESHOLD__DOC = (
121
141
  "Threshold, in seconds, below which the <delay> information is stripped "
122
142
  "out of emitted stanzas."
123
143
  )
144
+ IGNORE_DELAY_THRESHOLD__CATEGORY = _Categories.ADVANCED
124
145
 
125
146
  PARTIAL_REGISTRATION_TIMEOUT = 3600
126
147
  PARTIAL_REGISTRATION_TIMEOUT__DOC = (
127
148
  "Timeout before registration and login. Only useful for legacy networks where "
128
149
  "a single step registration process is not enough."
129
150
  )
151
+ PARTIAL_REGISTRATION_TIMEOUT__CATEGORY = _Categories.ADVANCED
130
152
 
131
153
  QR_TIMEOUT = 60
132
154
  QR_TIMEOUT__DOC = "Timeout for QR code flashing confirmation."
155
+ QR_TIMEOUT__CATEGORY = _Categories.ADVANCED
133
156
 
134
157
  FIX_FILENAME_SUFFIX_MIME_TYPE = False
135
158
  FIX_FILENAME_SUFFIX_MIME_TYPE__DOC = (
@@ -138,9 +161,11 @@ FIX_FILENAME_SUFFIX_MIME_TYPE__DOC = (
138
161
  " Therefore the MIME Type of the file is checked, if the suffix is not valid for"
139
162
  " that MIME Type, a valid one will be picked."
140
163
  )
164
+ FIX_FILENAME_SUFFIX_MIME_TYPE__CATEGORY = _Categories.ATTACHMENTS
141
165
 
142
166
  LOG_FILE: Optional[Path] = None
143
167
  LOG_FILE__DOC = "Log to a file instead of stdout/err"
168
+ LOG_FILE__CATEGORY = _Categories.LOG
144
169
 
145
170
  LOG_FORMAT: str = "%(levelname)s:%(name)s:%(message)s"
146
171
  LOG_FORMAT__DOC = (
@@ -148,41 +173,50 @@ LOG_FORMAT__DOC = (
148
173
  "https://docs.python.org/3/library/logging.html#logrecord-attributes "
149
174
  "for available options."
150
175
  )
176
+ LOG_FORMAT__CATEGORY = _Categories.LOG
151
177
 
152
178
  MAM_MAX_DAYS = 7
153
179
  MAM_MAX_DAYS__DOC = "Maximum number of days for group archive retention."
180
+ MAM_MAX_DAYS__CATEGORY = _Categories.BASE
154
181
 
155
182
  ATTACHMENT_MAXIMUM_FILE_NAME_LENGTH = 200
156
183
  ATTACHMENT_MAXIMUM_FILE_NAME_LENGTH__DOC = (
157
184
  "Some legacy network provide ridiculously long filenames, strip above this limit, "
158
185
  "preserving suffix."
159
186
  )
187
+ ATTACHMENT_MAXIMUM_FILE_NAME_LENGTH__CATEGORY = _Categories.ATTACHMENTS
160
188
 
161
189
  AVATAR_RESAMPLING_THREADS = 2
162
190
  AVATAR_RESAMPLING_THREADS__DOC = (
163
191
  "Number of additional threads to use for avatar resampling. Even in a single-core "
164
192
  "context, this makes avatar resampling non-blocking."
165
193
  )
194
+ AVATAR_RESAMPLING_THREADS__CATEGORY = _Categories.ADVANCED
166
195
 
167
196
  DEV_MODE = False
168
197
  DEV_MODE__DOC = (
169
198
  "Enables an interactive python shell via chat commands, for admins."
170
199
  "Not safe to use in prod, but great during dev."
171
200
  )
201
+ DEV_MODE__CATEGORY = _Categories.ADVANCED
202
+
172
203
 
173
204
  STRIP_LEADING_EMOJI_ADHOC = False
174
205
  STRIP_LEADING_EMOJI_ADHOC__DOC = (
175
206
  "Strip the leading emoji in ad-hoc command names, if present, in case you "
176
207
  "are a emoji-hater."
177
208
  )
209
+ STRIP_LEADING_EMOJI_ADHOC__CATEGORY = _Categories.ADVANCED
178
210
 
179
211
  COMPONENT_NAME: Optional[str] = None
180
212
  COMPONENT_NAME__DOC = (
181
213
  "Overrides the default component name with a custom one. This is seen in service discovery and as the nickname "
182
214
  "of the component in chat windows."
183
215
  )
216
+ COMPONENT_NAME__CATEGORY = _Categories.ADVANCED
184
217
 
185
218
  WELCOME_MESSAGE: Optional[str] = None
186
219
  WELCOME_MESSAGE__DOC = (
187
220
  "Overrides the default welcome message received by newly registered users."
188
221
  )
222
+ WELCOME_MESSAGE__CATEGORY = _Categories.ADVANCED
@@ -315,6 +315,7 @@ class MessageContentMixin(DispatcherMixin):
315
315
  raise XMPPError(
316
316
  "policy-violation",
317
317
  text=error_msg,
318
+ clear=False,
318
319
  )
319
320
 
320
321
  await session.on_react(recipient, legacy_id, emojis, thread=thread)
@@ -71,6 +71,7 @@ class PresenceHandlerMixin(DispatcherMixin):
71
71
  return
72
72
 
73
73
  await contact.on_friend_accept()
74
+ contact.send_last_presence(force=True)
74
75
 
75
76
  @exceptions_to_xmpp_errors
76
77
  async def _handle_unsubscribed(self, pres: Presence) -> None:
@@ -60,7 +60,7 @@ class RegistrationMixin(DispatcherMixin):
60
60
  )
61
61
  orm.add(user)
62
62
  orm.commit()
63
- log.info("New user: %s", user)
63
+ log.info("New user: %s", user)
64
64
 
65
65
  async def _user_modify(
66
66
  self, _gateway_jid, _node, ifrom: JID, form_dict: dict[str, Optional[str]]
@@ -3,7 +3,6 @@ from typing import TYPE_CHECKING
3
3
  from slixmpp import JID, CoroutineCallback, Iq, StanzaPath
4
4
  from slixmpp.exceptions import XMPPError
5
5
 
6
- from ...db.models import GatewayUser
7
6
  from .util import DispatcherMixin, exceptions_to_xmpp_errors
8
7
 
9
8
  if TYPE_CHECKING:
@@ -32,10 +31,7 @@ class SearchMixin(DispatcherMixin):
32
31
  """
33
32
  Prepare the search form using :attr:`.BaseSession.SEARCH_FIELDS`
34
33
  """
35
- with self.xmpp.store.session() as orm:
36
- user = orm.query(GatewayUser).one_or_none()
37
- if user is None:
38
- raise XMPPError(text="Search is only allowed for registered users")
34
+ await self._get_session(iq)
39
35
 
40
36
  xmpp = self.xmpp
41
37
 
@@ -172,7 +172,9 @@ def exceptions_to_xmpp_errors(cb: HandlerType) -> HandlerType:
172
172
  except NotImplementedError:
173
173
  log.debug("NotImplementedError raised in %s", cb)
174
174
  raise XMPPError(
175
- "feature-not-implemented", "Not implemented by the legacy module"
175
+ "feature-not-implemented",
176
+ f"{cb.__name__} is not implemented by the legacy module",
177
+ clear=False,
176
178
  )
177
179
  except Exception as e:
178
180
  log.error("Failed to handle incoming stanza: %s", args, exc_info=e)
slidge/core/gateway.py CHANGED
@@ -330,6 +330,7 @@ class BaseGateway(
330
330
  self.jid_validator: re.Pattern = re.compile(config.USER_JID_VALIDATOR)
331
331
  self.qr_pending_registrations = dict[str, asyncio.Future[Optional[dict]]]()
332
332
 
333
+ self.register_plugins()
333
334
  self.__setup_legacy_module_subclasses()
334
335
 
335
336
  self.get_session_from_stanza: Callable[
@@ -339,7 +340,6 @@ class BaseGateway(
339
340
  self._session_cls.from_user
340
341
  )
341
342
 
342
- self.register_plugins()
343
343
  self.__register_slixmpp_events()
344
344
  self.__register_slixmpp_api()
345
345
  self.roster.set_backend(RosterBackend(self))
@@ -390,6 +390,16 @@ class BaseGateway(
390
390
  bookmarks_cls = LegacyBookmarks.get_self_or_unique_subclass()
391
391
  roster_cls = LegacyRoster.get_self_or_unique_subclass()
392
392
 
393
+ if contact_cls.REACTIONS_SINGLE_EMOJI: # type:ignore[attr-defined]
394
+ form = Form()
395
+ form["type"] = "result"
396
+ form.add_field(
397
+ "FORM_TYPE", "hidden", value="urn:xmpp:reactions:0:restrictions"
398
+ )
399
+ form.add_field("max_reactions_per_user", value="1", type="number")
400
+ form.add_field("scope", value="domain")
401
+ self.plugin["xep_0128"].add_extended_info(data=form)
402
+
393
403
  session_cls.xmpp = self # type:ignore[attr-defined]
394
404
  contact_cls.xmpp = self # type:ignore[attr-defined]
395
405
  muc_cls.xmpp = self # type:ignore[attr-defined]
@@ -1002,6 +1012,7 @@ SLIXMPP_PLUGINS = [
1002
1012
  "xep_0106", # JID Escaping
1003
1013
  "xep_0115", # Entity capabilities
1004
1014
  "xep_0122", # Data Forms Validation
1015
+ "xep_0128", # Service Discovery Extensions
1005
1016
  "xep_0153", # vCard-Based Avatars (for MUC avatars)
1006
1017
  "xep_0172", # User nickname
1007
1018
  "xep_0184", # Message Delivery Receipts
@@ -162,15 +162,12 @@ class AttachmentMixin(TextMessageMixin):
162
162
  legacy_file_id=None
163
163
  if attachment.legacy_file_id is None
164
164
  else str(attachment.legacy_file_id),
165
- url=attachment.url,
165
+ url=attachment.url if config.USE_ATTACHMENT_ORIGINAL_URLS else None,
166
166
  )
167
167
 
168
168
  async def __get_url(
169
169
  self, attachment: LegacyAttachment, stored: Attachment
170
170
  ) -> tuple[bool, Optional[Path], str]:
171
- if attachment.url and config.USE_ATTACHMENT_ORIGINAL_URLS:
172
- return False, None, attachment.url
173
-
174
171
  file_name = attachment.name
175
172
  content_type = attachment.content_type
176
173
  file_path = attachment.path
@@ -221,7 +218,9 @@ class AttachmentMixin(TextMessageMixin):
221
218
 
222
219
  assert isinstance(file_path, Path)
223
220
  if config.FIX_FILENAME_SUFFIX_MIME_TYPE:
224
- file_name = str(fix_suffix(file_path, content_type, file_name))
221
+ file_name, content_type = fix_suffix(file_path, content_type, file_name)
222
+ attachment.content_type = content_type
223
+ attachment.name = file_name
225
224
 
226
225
  if config.NO_UPLOAD_PATH:
227
226
  local_path, new_url = await self.__no_upload(
@@ -312,6 +311,50 @@ class AttachmentMixin(TextMessageMixin):
312
311
  stored.sfs = str(sfs)
313
312
  msg.append(sfs)
314
313
 
314
+ async def __set_sfs_and_sims_without_download(
315
+ self, msg: Message, attachment: LegacyAttachment
316
+ ) -> None:
317
+ assert attachment.url is not None
318
+
319
+ if not any(
320
+ (
321
+ attachment.content_type,
322
+ attachment.name,
323
+ attachment.disposition,
324
+ )
325
+ ):
326
+ return
327
+
328
+ sims = self.xmpp.plugin["xep_0385"].stanza.Sims()
329
+ ref = self.xmpp["xep_0372"].stanza.Reference()
330
+
331
+ ref["uri"] = attachment.url
332
+ ref["type"] = "data"
333
+ sims["sources"].append(ref)
334
+ sims.enable("file")
335
+
336
+ xep_0447_stanza = self.xmpp.plugin["xep_0447"].stanza
337
+ sfs = xep_0447_stanza.StatelessFileSharing()
338
+ url_data = xep_0447_stanza.UrlData()
339
+ url_data["target"] = attachment.url
340
+ sfs["sources"].append(url_data)
341
+ sfs.enable("file")
342
+
343
+ if attachment.content_type:
344
+ sims["file"]["media-type"] = attachment.content_type
345
+ sfs["file"]["media-type"] = attachment.content_type
346
+ if attachment.caption:
347
+ sims["file"]["desc"] = attachment.caption
348
+ sfs["file"]["desc"] = attachment.caption
349
+ if attachment.name:
350
+ sims["file"]["name"] = attachment.name
351
+ sfs["file"]["name"] = attachment.name
352
+ if attachment.disposition:
353
+ sfs["disposition"] = attachment.disposition
354
+
355
+ msg.append(sims)
356
+ msg.append(sfs)
357
+
315
358
  def __send_url(
316
359
  self,
317
360
  msg: Message,
@@ -325,6 +368,9 @@ class AttachmentMixin(TextMessageMixin):
325
368
  ) -> list[Message]:
326
369
  msg["oob"]["url"] = uploaded_url
327
370
  msg["body"] = uploaded_url
371
+ if msg.get_plugin("sfs", check=True):
372
+ msg["fallback"].enable("body")
373
+ msg["fallback"]["for"] = self.xmpp.plugin["xep_0447"].stanza.NAMESPACE
328
374
  if caption:
329
375
  m1 = self._send(msg, carbon=carbon, **kwargs)
330
376
  m2 = self.send_text(
@@ -465,7 +511,10 @@ class AttachmentMixin(TextMessageMixin):
465
511
  new_url = stored.url
466
512
  else:
467
513
  is_temp, local_path, new_url = await self.__get_url(attachment, stored)
468
- if new_url is None:
514
+ if new_url is None or (
515
+ local_path is not None and local_path.stat().st_size == 0
516
+ ):
517
+ log.warning("Something went wrong with this attachment: %s", attachment)
469
518
  msg["body"] = (
470
519
  "I tried to send a file, but something went wrong. "
471
520
  "Tell your slidge admin to check the logs."
@@ -474,8 +523,13 @@ class AttachmentMixin(TextMessageMixin):
474
523
  return None, [self._send(msg, **kwargs)]
475
524
 
476
525
  stored.url = new_url
477
- thumbnail = await self.__set_sims(msg, new_url, local_path, attachment, stored)
478
- self.__set_sfs(msg, new_url, local_path, attachment, stored, thumbnail)
526
+ if config.USE_ATTACHMENT_ORIGINAL_URLS and attachment.url:
527
+ await self.__set_sfs_and_sims_without_download(msg, attachment)
528
+ else:
529
+ thumbnail = await self.__set_sims(
530
+ msg, new_url, local_path, attachment, stored
531
+ )
532
+ self.__set_sfs(msg, new_url, local_path, attachment, stored, thumbnail)
479
533
 
480
534
  if self.session is not NotImplemented:
481
535
  with self.xmpp.store.session(expire_on_commit=False) as orm:
@@ -112,7 +112,7 @@ class MessageMaker(BaseSender):
112
112
  if when.tzinfo is None:
113
113
  when = when.astimezone(timezone.utc)
114
114
  if self.STRIP_SHORT_DELAY:
115
- delay = datetime.now().astimezone(timezone.utc) - when
115
+ delay = (datetime.now().astimezone(timezone.utc) - when).seconds
116
116
  if delay < config.IGNORE_DELAY_THRESHOLD:
117
117
  return
118
118
  msg["delay"].set_stamp(when)
@@ -191,7 +191,7 @@ class TextMessageMixin(MessageMaker):
191
191
  xmpp_id = kwargs.pop("xmpp_id", None)
192
192
  if not xmpp_id:
193
193
  xmpp_id = self._legacy_to_xmpp(legacy_msg_id)
194
- self.xmpp["xep_0444"].set_reactions(msg, to_id=xmpp_id, reactions=emojis)
194
+ self.xmpp["xep_0444"].set_reactions(msg, to_id=xmpp_id, reactions=set(emojis))
195
195
  self.__add_reaction_fallback(msg, legacy_msg_id, emojis)
196
196
  self._send(msg, **kwargs)
197
197
 
@@ -46,6 +46,8 @@ def _clear_last_seen_task(contact_pk: int, _task) -> None:
46
46
  class PresenceMixin(BaseSender, DBMixin):
47
47
  _ONLY_SEND_PRESENCE_CHANGES = False
48
48
 
49
+ # this attribute actually only exists for contacts and not participants
50
+ _updating_info: bool
49
51
  stored: Contact | Participant
50
52
 
51
53
  def __init__(self, *a, **k) -> None:
@@ -55,15 +57,24 @@ class PresenceMixin(BaseSender, DBMixin):
55
57
  # to DB at the end of update_info()
56
58
  self.cached_presence: Optional[CachedPresence] = None
57
59
 
60
+ def __is_contact(self) -> bool:
61
+ return isinstance(self.stored, Contact)
62
+
58
63
  def __stored(self) -> Contact | None:
59
- if isinstance(self.stored, Contact):
64
+ if self.__is_contact():
65
+ assert isinstance(self.stored, Contact)
60
66
  return self.stored
61
67
  else:
68
+ assert isinstance(self.stored, Participant)
62
69
  try:
63
70
  return self.stored.contact
64
71
  except DetachedInstanceError:
65
72
  with self.xmpp.store.session() as orm:
66
73
  orm.add(self.stored)
74
+ if self.stored.contact is None:
75
+ return None
76
+ orm.refresh(self.stored.contact)
77
+ orm.merge(self.stored)
67
78
  return self.stored.contact
68
79
 
69
80
  @property
@@ -85,15 +96,22 @@ class PresenceMixin(BaseSender, DBMixin):
85
96
  )
86
97
 
87
98
  def _store_last_presence(self, new: CachedPresence) -> None:
88
- stored = self.__stored()
89
- if stored is not None:
90
- stored.cached_presence = True
91
- for k, v in new._asdict().items():
92
- setattr(stored, k, v)
99
+ stored_contact = self.__stored()
100
+ if stored_contact is None:
101
+ return
102
+ stored_contact.cached_presence = True
103
+ for k, v in new._asdict().items():
104
+ setattr(stored_contact, k, v)
105
+ if self.__is_contact() and self._updating_info:
106
+ return
107
+ with self.xmpp.store.session(expire_on_commit=False) as orm:
93
108
  try:
94
- self.commit()
109
+ orm.add(stored_contact)
95
110
  except InvalidRequestError:
96
- self.commit(merge=True)
111
+ stored_contact = orm.merge(stored_contact)
112
+ orm.add(stored_contact)
113
+
114
+ orm.commit()
97
115
 
98
116
  def _make_presence(
99
117
  self,
slidge/core/session.py CHANGED
@@ -719,6 +719,8 @@ class BaseSession(
719
719
  log.warning("User not found during unregistration")
720
720
  return
721
721
 
722
+ session.cancel_all_tasks()
723
+
722
724
  await cls.xmpp.unregister(session)
723
725
  with cls.xmpp.store.session() as orm:
724
726
  orm.delete(session.user)
slidge/db/models.py CHANGED
@@ -7,7 +7,7 @@ import sqlalchemy as sa
7
7
  from slixmpp import JID
8
8
  from slixmpp.types import MucAffiliation, MucRole
9
9
  from sqlalchemy import JSON, ForeignKey, Index, UniqueConstraint
10
- from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship
10
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
11
11
 
12
12
  from ..util.types import ClientType, MucType
13
13
  from .meta import Base, JSONSerializable, JSONSerializableTypes
slidge/db/store.py CHANGED
@@ -7,9 +7,10 @@ from datetime import datetime, timedelta, timezone
7
7
  from mimetypes import guess_extension
8
8
  from typing import Collection, Iterator, Optional, Type
9
9
 
10
+ import sqlalchemy as sa
10
11
  from slixmpp.exceptions import XMPPError
11
12
  from slixmpp.plugins.xep_0231.stanza import BitsOfBinary
12
- from sqlalchemy import Engine, delete, select, update
13
+ from sqlalchemy import Engine, delete, event, select, update
13
14
  from sqlalchemy.exc import InvalidRequestError
14
15
  from sqlalchemy.orm import Session, attributes, sessionmaker
15
16
 
@@ -20,6 +21,7 @@ from .meta import Base
20
21
  from .models import (
21
22
  ArchivedMessage,
22
23
  ArchivedMessageSource,
24
+ Avatar,
23
25
  Bob,
24
26
  Contact,
25
27
  ContactSent,
@@ -213,6 +215,7 @@ class ContactStore(UpdatedMixin):
213
215
  def __init__(self, session: Session) -> None:
214
216
  super().__init__(session)
215
217
  session.execute(update(Contact).values(cached_presence=False))
218
+ session.execute(update(Contact).values(caps_ver=None))
216
219
 
217
220
  @staticmethod
218
221
  def add_to_sent(session: Session, contact_pk: int, msg_id: str) -> None:
@@ -589,4 +592,29 @@ class BobStore:
589
592
  session.add(row)
590
593
 
591
594
 
595
+ @event.listens_for(sa.orm.Session, "after_flush")
596
+ def _check_avatar_orphans(session, flush_context):
597
+ if not session.deleted:
598
+ return
599
+
600
+ potentially_orphaned = set()
601
+ for obj in session.deleted:
602
+ if isinstance(obj, (Contact, Room)) and obj.avatar_id:
603
+ potentially_orphaned.add(obj.avatar_id)
604
+ if not potentially_orphaned:
605
+ return
606
+
607
+ result = session.execute(
608
+ sa.delete(Avatar).where(
609
+ sa.and_(
610
+ Avatar.id.in_(potentially_orphaned),
611
+ sa.not_(sa.exists().where(Contact.avatar_id == Avatar.id)),
612
+ sa.not_(sa.exists().where(Room.avatar_id == Avatar.id)),
613
+ )
614
+ )
615
+ )
616
+ deleted_count = result.rowcount
617
+ log.debug(f"Auto-deleted %s orphaned avatars", deleted_count)
618
+
619
+
592
620
  log = logging.getLogger(__name__)
slidge/group/room.py CHANGED
@@ -296,7 +296,7 @@ class LegacyMUC(
296
296
  self, affiliation: Optional[MucAffiliation] = None
297
297
  ) -> AsyncIterator[LegacyParticipantType]:
298
298
  await self.__fill_participants()
299
- with self.xmpp.store.session(expire_on_commit=False) as orm:
299
+ with self.xmpp.store.session(expire_on_commit=False, autoflush=False) as orm:
300
300
  orm.add(self.stored)
301
301
  for db_participant in self.stored.participants:
302
302
  if (
@@ -524,6 +524,9 @@ class LegacyMUC(
524
524
  if s := self.subject:
525
525
  form.add_field("muc#roominfo_subject", value=s)
526
526
 
527
+ if name := self.name:
528
+ form.add_field("muc#roomconfig_roomname", value=name)
529
+
527
530
  if self._set_avatar_task is not None:
528
531
  await self._set_avatar_task
529
532
  avatar = self.get_avatar()
@@ -652,7 +655,7 @@ class LegacyMUC(
652
655
  with orm.no_autoflush:
653
656
  orm.refresh(self.stored, ["participants"])
654
657
  if not user_participant.is_user:
655
- self.log.warning("is_user flag not set participant on user_participant")
658
+ self.log.warning("is_user flag not set on user_participant")
656
659
  user_participant.is_user = True
657
660
  user_participant.send_initial_presence(
658
661
  user_full_jid,
slidge/util/test.py CHANGED
@@ -42,7 +42,7 @@ from slidge import (
42
42
 
43
43
  from ..command import Command
44
44
  from ..core import config
45
- from ..core.config import _TimedeltaSeconds
45
+ from ..core.session import _sessions
46
46
  from ..db import SlidgeStore
47
47
  from ..db.avatar import avatar_cache
48
48
  from ..db.meta import Base
@@ -206,7 +206,7 @@ class SlidgeTest(SlixTestPlus):
206
206
  user_jid_validator = ".*"
207
207
  admins: list[str] = []
208
208
  upload_requester = None
209
- ignore_delay_threshold = _TimedeltaSeconds("300")
209
+ ignore_delay_threshold = 300
210
210
 
211
211
  @classmethod
212
212
  def setUpClass(cls) -> None:
@@ -281,6 +281,7 @@ class SlidgeTest(SlixTestPlus):
281
281
  self.db_engine.echo = False
282
282
  super().tearDown()
283
283
  Base.metadata.drop_all(self.xmpp.store._engine)
284
+ _sessions.clear()
284
285
 
285
286
  def setup_logged_session(self, n_contacts: int = 0) -> None:
286
287
  with self.xmpp.store.session() as orm:
slidge/util/util.py CHANGED
@@ -34,7 +34,9 @@ except ImportError as e:
34
34
  )
35
35
 
36
36
 
37
- def fix_suffix(path: Path, mime_type: Optional[str], file_name: Optional[str]) -> Path:
37
+ def fix_suffix(
38
+ path: Path, mime_type: Optional[str], file_name: Optional[str]
39
+ ) -> tuple[str, str]:
38
40
  guessed = magic.from_file(path, mime=True)
39
41
  if guessed == mime_type:
40
42
  log.debug("Magic and given MIME match")
@@ -53,15 +55,15 @@ def fix_suffix(path: Path, mime_type: Optional[str], file_name: Optional[str]) -
53
55
 
54
56
  if suffix in valid_suffix_list:
55
57
  log.debug("Suffix %s is in %s", suffix, valid_suffix_list)
56
- return name
58
+ return str(name), guessed
57
59
 
58
60
  valid_suffix = mimetypes.guess_extension(mime_type.split(";")[0], strict=False)
59
61
  if valid_suffix is None:
60
62
  log.debug("No valid suffix found")
61
- return name
63
+ return str(name), guessed
62
64
 
63
65
  log.debug("Changing suffix of %s to %s", file_name or path.name, valid_suffix)
64
- return name.with_suffix(valid_suffix)
66
+ return str(name.with_suffix(valid_suffix)), guessed
65
67
 
66
68
 
67
69
  class SubclassableOnce(type):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slidge
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: XMPP bridging framework
5
5
  Author-email: Nicolas Cedilnik <nicoco@nicoco.fr>
6
6
  License-Expression: AGPL-3.0-or-later
@@ -12,26 +12,26 @@ slidge/command/chat_command.py,sha256=r_qalygOCsEoS-OgWw8IIlAzTufhXNjduONbeoepUI
12
12
  slidge/command/register.py,sha256=BduDI31Kx8CbWWEdjybimTA5Wcfhn-Jkt8sSPsySCpo,6724
13
13
  slidge/command/user.py,sha256=xt7NTb8Fdj_Mx91BgiszSii6QF67LtsyowQ1vhBJSIU,12660
14
14
  slidge/contact/__init__.py,sha256=WMMaHk7UW7YT9EH2LtPdkU0bHQaOp4ikBhbBQskmoc8,191
15
- slidge/contact/contact.py,sha256=uXHxfEsBNjTE0Tdk1s1_YETIazU03-pvaScd3QQmlu0,19607
16
- slidge/contact/roster.py,sha256=uZ3iCl8oa7kEpIptUVa9p1k51bSvbuQq1pUGnf_zru8,9791
15
+ slidge/contact/contact.py,sha256=ASNjGj-fxueC7rReMw4qMDUQxJ58W1F8i4Ipu_wfsTE,19617
16
+ slidge/contact/roster.py,sha256=ZbhPC_5opMXdKrdCaQZiHTErxQD4EyWn5Susl6W7M6k,10003
17
17
  slidge/core/__init__.py,sha256=RG7Jj5JCJERjhqJ31lOLYV-7bH_oblClQD1KF9LsTXo,68
18
- slidge/core/config.py,sha256=1HxHQ5BOnYghi8V5KbCQ6sUsNnXzAZAIoFA5PrMqH4E,6060
19
- slidge/core/gateway.py,sha256=5GImsd1b7KKcwIZcNSWgVieezrnp5dJCv7IlmOvWkRc,41192
18
+ slidge/core/config.py,sha256=yFD1RGBZ6Xl115oMqDiF7DFkShlNjF9HEB56Eb_BR_E,7664
19
+ slidge/core/gateway.py,sha256=G_ZX8ibKFe-oEXXOY-eRVkZjqRb_VdadIhTezwZEUCA,41698
20
20
  slidge/core/pubsub.py,sha256=2Em3PvYz-PX-WM7ZqkEU9estNYCyQ--JdJq22DhrUlA,12145
21
- slidge/core/session.py,sha256=leHLahwpI5_hqR6xv0juw_BQJnpuPqVmhOIwYEiPnx8,29861
21
+ slidge/core/session.py,sha256=XJRZrtAmwqqjaqkLEUPK8xUBxdJrKeraubYYNiiuT7I,29897
22
22
  slidge/core/dispatcher/__init__.py,sha256=1EXcjXietUKlxEqdrCWCV3xZ3q_DSsjHoqWrPMbtYao,84
23
23
  slidge/core/dispatcher/caps.py,sha256=gISaHtFwFDXtkSrSsAkZfPiHQyXfmXg3v_YYU0w9iDg,2254
24
24
  slidge/core/dispatcher/disco.py,sha256=xVPyBFnnkON-JjjM1oydRa-dqnDbwAZER2MGQACRhVk,2309
25
- slidge/core/dispatcher/presence.py,sha256=3qGsiMGzTDnOpMbCRJ8RCrRGiSU-pAyOLJHphbZF5pw,7043
26
- slidge/core/dispatcher/registration.py,sha256=Qa3fYZFJ4NaVz-FsnXorCmMQ9WyO7oZte1Zvro5f74E,3415
27
- slidge/core/dispatcher/search.py,sha256=bL5cwMOtsfnX5IH-t60S1PudpeMWZnF4-qksrKDuDyc,3411
25
+ slidge/core/dispatcher/presence.py,sha256=MkSOY7uZQnujTwhIU_nVxygTul63VBaPjTT3X4iTMu4,7090
26
+ slidge/core/dispatcher/registration.py,sha256=R4bsyiR8elbLKiFMYv2E54VAUg6qTo-_2CzdbCOZjrY,3419
27
+ slidge/core/dispatcher/search.py,sha256=j7LGht2M36FfhXRzcdZiV-8OdCVsVHb2vJb6p3pIbpk,3202
28
28
  slidge/core/dispatcher/session_dispatcher.py,sha256=ysgPhns7NgUxhmkgEwRv-yDkSnUIXEdc-FsgqDqQAkE,3466
29
- slidge/core/dispatcher/util.py,sha256=DsMWz7yS67E2Y2pcnXUkuVMTa9F_Ok0JIxjhqhdYpD0,6161
29
+ slidge/core/dispatcher/util.py,sha256=yxNevqjjLOzApLpqIRFHNQmpsahwZn55qCsNkOSXohc,6225
30
30
  slidge/core/dispatcher/vcard.py,sha256=qHZZShq3Iyvgh1FkcAgGhdKXF5m1VUqeb4EWkY0cbFw,5203
31
31
  slidge/core/dispatcher/message/__init__.py,sha256=gNeZZ0wtCI9JBqMe6tpumwV1TjY0mnPWTJc94uFTN-I,244
32
32
  slidge/core/dispatcher/message/chat_state.py,sha256=RbtM_nlZyvOHusZkDEP0TXA4wMp_N435490eE4wW8U0,2143
33
33
  slidge/core/dispatcher/message/marker.py,sha256=WZyf72_SM6sDGPMEOzhu93o2KbgxSxNF25jwsiM7h2A,2439
34
- slidge/core/dispatcher/message/message.py,sha256=RK1sX4cBSvPZLiUGYoT-pdCB01ll1761Ryr7Tu0AuBc,16202
34
+ slidge/core/dispatcher/message/message.py,sha256=h-oNEC6In3dH2TpMgRVyMAvZjvWDczaq8nowOzVz_R4,16231
35
35
  slidge/core/dispatcher/muc/__init__.py,sha256=60YUr0i8PCZEPyNNTynJueRbbxF5pqzdyVf8z_XFXmM,290
36
36
  slidge/core/dispatcher/muc/admin.py,sha256=1tDZ9hHD6q5SqCjsYOpDimPB3Iyl21YO5RnK1goEGso,3284
37
37
  slidge/core/dispatcher/muc/mam.py,sha256=7vfmMI_mJOIrc9KCbtTibJSowhZTBBFwXWc84Ikpu3I,2994
@@ -39,21 +39,21 @@ slidge/core/dispatcher/muc/misc.py,sha256=DgUCSVwcv7kD5xmkS59E-TGf9yWDZiu6NBHgVS
39
39
  slidge/core/dispatcher/muc/owner.py,sha256=dDAxpRaA8H_NJQNIyBNixck2oG4GHZeEQqPhKd7MmDQ,3359
40
40
  slidge/core/dispatcher/muc/ping.py,sha256=EgKKS9AvMnW-vINGcoGbtk6NdbN9A7zVaGfT5T7F6YE,1699
41
41
  slidge/core/mixins/__init__.py,sha256=Zea39CCwjJU5XfHwcYPEZ9Sin8z1BZxoV68G2RwC3nE,386
42
- slidge/core/mixins/attachment.py,sha256=6c1gZNydVZHgHB-_PjBLbT_9ntBSNG0AWqlv9Cgcku8,21906
42
+ slidge/core/mixins/attachment.py,sha256=k-4nwteql4TmhsZDLqPVgzVyuaRQzk-JEOG-k7w-VxM,23938
43
43
  slidge/core/mixins/avatar.py,sha256=0E0mQxdTUcJQrYXlBkYqkNl4bYuga4cIC1s4XA2rED8,5559
44
44
  slidge/core/mixins/base.py,sha256=getXMptzJwIc4fEbeMoJCSKcC3awi8UbKnx5FVCthjc,783
45
45
  slidge/core/mixins/db.py,sha256=lryMfRB-1-St2f7TZqW4sCl4xrWPWNlwgvn37T8wQPE,2678
46
46
  slidge/core/mixins/disco.py,sha256=mrYhWO9qpnLMAVtKKqwbDh6CNOH2dPNERpyfmWzZGg8,3684
47
47
  slidge/core/mixins/message.py,sha256=xk4bgiJF9ATe-rgtH4sHU8hUwecBF4KjGIujm90mbXQ,8014
48
- slidge/core/mixins/message_maker.py,sha256=NaIQzpjYHKMuuqfqncviB6AE-q50-YhhstxZ-WhLWhE,6546
49
- slidge/core/mixins/message_text.py,sha256=-hlGDzQ9dya-ZCPBe3v7UrpBetQa36N3YIACRxBt8Yc,9632
50
- slidge/core/mixins/presence.py,sha256=DPNatkVIkw7GmHrkyUlUgsGhp0VUgaUgSBuhGalbe8c,9791
48
+ slidge/core/mixins/message_maker.py,sha256=jdY0dhpN_RcKwajI0XrV194ruNW120Gfdq333B1Fv1o,6556
49
+ slidge/core/mixins/message_text.py,sha256=Unzmwr8TnPC2uuZ2fFiIalOGpQF7YYyT1AbB8i0l6Qc,9637
50
+ slidge/core/mixins/presence.py,sha256=hNttLzQ83q6jTQumG-ATpTj85DiUpF50FebYQV_Nazg,10530
51
51
  slidge/core/mixins/recipient.py,sha256=b0uFnpym-hOFgYxGjXT1xQcZ4YRbDSBftPcNWLzSwEI,1336
52
52
  slidge/db/__init__.py,sha256=EBDH1JSEhgqYcli2Bw11CRC749wJk8AOucgBzmhDSvU,105
53
53
  slidge/db/avatar.py,sha256=MXFd1oe0eL5CCUYbc5CpsIcbio3cY3xVoKt39RAoj9I,8240
54
54
  slidge/db/meta.py,sha256=NtjGWcqPfG7uPfwR_cC6_23zyo8ftqgKX8CbP9IBq6U,2185
55
- slidge/db/models.py,sha256=8z5bbaEINfU1Qx12iyHu4zRD9s3PwC6oUOz-PyBoqYg,12841
56
- slidge/db/store.py,sha256=ZksmZlFaTia7eWy_trALc_iTEkk2x7CIlEQeYkHW21g,19408
55
+ slidge/db/models.py,sha256=HMSiNY0j-sm0VsikkVED1swPhX687613uW7NEHB8UTs,12826
56
+ slidge/db/store.py,sha256=yhvYgoXpbueprjVUXBmA25wFyEbzDXMxVzqv8aZi5tw,20308
57
57
  slidge/db/alembic/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
58
58
  slidge/db/alembic/env.py,sha256=hsBlRNs0zF5diSHGRSa8Fi3qRVQDA2rJdR41AEIdvxc,1642
59
59
  slidge/db/alembic/script.py.mako,sha256=MEqL-2qATlST9TAOeYgscMn1uy6HUS9NFvDgl93dMj8,635
@@ -62,7 +62,7 @@ slidge/group/__init__.py,sha256=yFt7cHqeaKIMN6f9ZyhhspOcJJvBtLedGv-iICG7lto,258
62
62
  slidge/group/archive.py,sha256=gNHpGlUstPtBcnBgOarSYzkZHo9E8bNxl_ZY3wM6wHg,6108
63
63
  slidge/group/bookmarks.py,sha256=eiXtlgirE59dBi1BT1349wCrGuHDkCoak1phCepzkqI,7653
64
64
  slidge/group/participant.py,sha256=2yDJL2bL0w-EiWjq6V96UEhXUgmih5-GpKsoKfG2zdY,18734
65
- slidge/group/room.py,sha256=lXzICLkv980o0JS-3HZTbvf2MD0ZlIw4-Y-xiU6lEew,49481
65
+ slidge/group/room.py,sha256=QXNEbJBY5gbSpVbr1-u-M2YOoIU1qGhWM7SvtdyiETs,49583
66
66
  slidge/slixfix/__init__.py,sha256=LvaYZQYjr4l_45AYYpod1dB3MUaZye18vKF-4H8Bm20,4758
67
67
  slidge/slixfix/delivery_receipt.py,sha256=JmogxsiXYEbTmCM4fvC5wkQs0jBsaJtKl4j_B_18riE,1415
68
68
  slidge/slixfix/roster.py,sha256=DjjHQqCsKsPChUxV7S0Pm4IAgjfrwgm5tcTdJi3N_gY,1670
@@ -84,12 +84,12 @@ slidge/util/archive_msg.py,sha256=hGNquu38ouSWSc-kz_oAYPXwjhUVZNSedIpwkrXHSd0,18
84
84
  slidge/util/conf.py,sha256=Wv-xr1fQfz6jDCBpj2e5Nm-igMpdIjsYsVfoY8grJoo,7380
85
85
  slidge/util/jid_escaping.py,sha256=QJ2Yj_j1gTmiO9g2r187iVCu7kia_O5ABhRiLAO2TG4,1073
86
86
  slidge/util/lock.py,sha256=ZnUi3LGiz271-YeYKo9JzxovJCoSwlP9P65pNyHIO9o,1029
87
- slidge/util/test.py,sha256=_E6er2BtQlpyzTUmp4u8C9KhBYzbLrmTwSVgxGObKHU,13988
87
+ slidge/util/test.py,sha256=o86wX1ksnvAgN47k5facDPc4zgiK7J5oWyThgJsv_vQ,13986
88
88
  slidge/util/types.py,sha256=nilphTeJU3yb0MSqb86tZeWXis495oDvHSDDBs0hn_4,5747
89
- slidge/util/util.py,sha256=PBdHtcRIQi6Dy-yHASS_xiRGQ4Pv4i6QSSZAAELPnmQ,9536
90
- slidge-0.3.1.dist-info/licenses/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
91
- slidge-0.3.1.dist-info/METADATA,sha256=UEfy2L6fzCOoHu2yfUhlh0SI7cQW6LrC6XNhJySfYoo,5054
92
- slidge-0.3.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
93
- slidge-0.3.1.dist-info/entry_points.txt,sha256=py3_x834fFJ2TEzPd18Wt2DnysdAfuVqJ5zzBrXbAZs,44
94
- slidge-0.3.1.dist-info/top_level.txt,sha256=2LRjDYHaGZ5ieCMF8xy58JIiabRMzX-MGMbCZwfE17c,7
95
- slidge-0.3.1.dist-info/RECORD,,
89
+ slidge/util/util.py,sha256=g6jFu5xgAtRA9FBCIBJT3079t_bQKZYOUvi9L5pTbbk,9595
90
+ slidge-0.3.2.dist-info/licenses/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
91
+ slidge-0.3.2.dist-info/METADATA,sha256=Ptqr6X39gL1nyceti9rOscoPzsVcCBb8u93QsqRV6zU,5054
92
+ slidge-0.3.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
93
+ slidge-0.3.2.dist-info/entry_points.txt,sha256=py3_x834fFJ2TEzPd18Wt2DnysdAfuVqJ5zzBrXbAZs,44
94
+ slidge-0.3.2.dist-info/top_level.txt,sha256=2LRjDYHaGZ5ieCMF8xy58JIiabRMzX-MGMbCZwfE17c,7
95
+ slidge-0.3.2.dist-info/RECORD,,
File without changes