slidge 0.2.12__py3-none-any.whl → 0.3.0a0__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.
Files changed (77) hide show
  1. slidge/__init__.py +5 -2
  2. slidge/command/adhoc.py +9 -3
  3. slidge/command/admin.py +16 -12
  4. slidge/command/base.py +16 -12
  5. slidge/command/chat_command.py +25 -16
  6. slidge/command/user.py +7 -8
  7. slidge/contact/contact.py +119 -209
  8. slidge/contact/roster.py +106 -105
  9. slidge/core/config.py +2 -43
  10. slidge/core/dispatcher/caps.py +9 -2
  11. slidge/core/dispatcher/disco.py +13 -3
  12. slidge/core/dispatcher/message/__init__.py +1 -1
  13. slidge/core/dispatcher/message/chat_state.py +17 -8
  14. slidge/core/dispatcher/message/marker.py +7 -5
  15. slidge/core/dispatcher/message/message.py +117 -92
  16. slidge/core/dispatcher/muc/__init__.py +1 -1
  17. slidge/core/dispatcher/muc/admin.py +4 -4
  18. slidge/core/dispatcher/muc/mam.py +10 -6
  19. slidge/core/dispatcher/muc/misc.py +4 -2
  20. slidge/core/dispatcher/muc/owner.py +5 -3
  21. slidge/core/dispatcher/muc/ping.py +3 -1
  22. slidge/core/dispatcher/presence.py +21 -15
  23. slidge/core/dispatcher/registration.py +20 -12
  24. slidge/core/dispatcher/search.py +7 -3
  25. slidge/core/dispatcher/session_dispatcher.py +13 -5
  26. slidge/core/dispatcher/util.py +37 -27
  27. slidge/core/dispatcher/vcard.py +7 -4
  28. slidge/core/gateway.py +168 -84
  29. slidge/core/mixins/__init__.py +1 -11
  30. slidge/core/mixins/attachment.py +163 -148
  31. slidge/core/mixins/avatar.py +100 -177
  32. slidge/core/mixins/db.py +50 -2
  33. slidge/core/mixins/message.py +19 -17
  34. slidge/core/mixins/message_maker.py +29 -15
  35. slidge/core/mixins/message_text.py +38 -30
  36. slidge/core/mixins/presence.py +91 -35
  37. slidge/core/pubsub.py +42 -47
  38. slidge/core/session.py +88 -57
  39. slidge/db/alembic/versions/0337c90c0b96_unify_legacy_xmpp_id_mappings.py +183 -0
  40. slidge/db/alembic/versions/4dbd23a3f868_new_avatar_store.py +56 -0
  41. slidge/db/alembic/versions/54ce3cde350c_use_hash_for_avatar_filenames.py +50 -0
  42. slidge/db/alembic/versions/58b98dacf819_refactor.py +118 -0
  43. slidge/db/alembic/versions/75a62b74b239_ditch_hats_table.py +74 -0
  44. slidge/db/avatar.py +150 -119
  45. slidge/db/meta.py +33 -22
  46. slidge/db/models.py +68 -117
  47. slidge/db/store.py +412 -1094
  48. slidge/group/archive.py +61 -54
  49. slidge/group/bookmarks.py +74 -55
  50. slidge/group/participant.py +135 -142
  51. slidge/group/room.py +315 -312
  52. slidge/main.py +28 -18
  53. slidge/migration.py +2 -12
  54. slidge/slixfix/__init__.py +20 -4
  55. slidge/slixfix/delivery_receipt.py +6 -4
  56. slidge/slixfix/link_preview/link_preview.py +1 -1
  57. slidge/slixfix/link_preview/stanza.py +1 -1
  58. slidge/slixfix/roster.py +5 -7
  59. slidge/slixfix/xep_0077/register.py +8 -8
  60. slidge/slixfix/xep_0077/stanza.py +7 -7
  61. slidge/slixfix/xep_0100/gateway.py +12 -13
  62. slidge/slixfix/xep_0153/vcard_avatar.py +1 -1
  63. slidge/slixfix/xep_0292/vcard4.py +1 -1
  64. slidge/util/archive_msg.py +11 -5
  65. slidge/util/conf.py +23 -20
  66. slidge/util/jid_escaping.py +1 -1
  67. slidge/{core/mixins → util}/lock.py +6 -6
  68. slidge/util/test.py +30 -29
  69. slidge/util/types.py +22 -18
  70. slidge/util/util.py +19 -22
  71. {slidge-0.2.12.dist-info → slidge-0.3.0a0.dist-info}/METADATA +1 -1
  72. slidge-0.3.0a0.dist-info/RECORD +117 -0
  73. {slidge-0.2.12.dist-info → slidge-0.3.0a0.dist-info}/WHEEL +1 -1
  74. slidge-0.2.12.dist-info/RECORD +0 -112
  75. {slidge-0.2.12.dist-info → slidge-0.3.0a0.dist-info}/entry_points.txt +0 -0
  76. {slidge-0.2.12.dist-info → slidge-0.3.0a0.dist-info}/licenses/LICENSE +0 -0
  77. {slidge-0.2.12.dist-info → slidge-0.3.0a0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,118 @@
1
+ """Add unique constraints that we should have added before
2
+
3
+ Revision ID: 58b98dacf819
4
+ Revises: 54ce3cde350c
5
+ Create Date: 2025-04-23 22:55:11.731158
6
+
7
+ """
8
+
9
+ from typing import Sequence, Union
10
+
11
+ import sqlalchemy as sa
12
+ from alembic import op
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = "58b98dacf819"
16
+ down_revision: Union[str, None] = "54ce3cde350c"
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ # ### commands auto generated by Alembic - please adjust! ###
23
+ with op.batch_alter_table("avatar", schema=None) as batch_op:
24
+ batch_op.drop_constraint("avatar_unique_legacy_id", type_="unique")
25
+ batch_op.create_unique_constraint(batch_op.f("uq_avatar_hash"), ["hash"])
26
+ batch_op.create_unique_constraint(
27
+ batch_op.f("uq_avatar_legacy_id"), ["legacy_id"]
28
+ )
29
+
30
+ with op.batch_alter_table("bob", schema=None) as batch_op:
31
+ batch_op.create_unique_constraint(batch_op.f("uq_bob_sha_1"), ["sha_1"])
32
+ batch_op.create_unique_constraint(batch_op.f("uq_bob_sha_256"), ["sha_256"])
33
+ batch_op.create_unique_constraint(batch_op.f("uq_bob_sha_512"), ["sha_512"])
34
+
35
+ with op.batch_alter_table("contact", schema=None) as batch_op:
36
+ batch_op.create_unique_constraint(
37
+ batch_op.f("uq_contact_user_account_id"), ["user_account_id", "legacy_id"]
38
+ )
39
+
40
+ with op.batch_alter_table("contact_sent", schema=None) as batch_op:
41
+ batch_op.create_unique_constraint(
42
+ batch_op.f("uq_contact_sent_contact_id"), ["contact_id", "msg_id"]
43
+ )
44
+
45
+ with op.batch_alter_table("hat", schema=None) as batch_op:
46
+ batch_op.create_unique_constraint(batch_op.f("uq_hat_title"), ["title", "uri"])
47
+
48
+ with op.batch_alter_table("mam", schema=None) as batch_op:
49
+ batch_op.create_unique_constraint(
50
+ batch_op.f("uq_mam_room_id"), ["room_id", "stanza_id"]
51
+ )
52
+
53
+ with op.batch_alter_table("participant", schema=None) as batch_op:
54
+ batch_op.alter_column("resource", existing_type=sa.VARCHAR(), nullable=False)
55
+ batch_op.alter_column("nickname", existing_type=sa.VARCHAR(), nullable=False)
56
+ batch_op.alter_column(
57
+ "nickname_no_illegal", existing_type=sa.VARCHAR(), nullable=False
58
+ )
59
+ batch_op.create_unique_constraint(
60
+ batch_op.f("uq_participant_room_id"), ["room_id", "resource"]
61
+ )
62
+
63
+ with op.batch_alter_table("room", schema=None) as batch_op:
64
+ batch_op.alter_column(
65
+ "muc_type", existing_type=sa.VARCHAR(length=21), nullable=False
66
+ )
67
+
68
+ with op.batch_alter_table("user_account", schema=None) as batch_op:
69
+ batch_op.create_unique_constraint(batch_op.f("uq_user_account_jid"), ["jid"])
70
+
71
+ # ### end Alembic commands ###
72
+
73
+
74
+ def downgrade() -> None:
75
+ # ### commands auto generated by Alembic - please adjust! ###
76
+ with op.batch_alter_table("user_account", schema=None) as batch_op:
77
+ batch_op.drop_constraint(batch_op.f("uq_user_account_jid"), type_="unique")
78
+
79
+ with op.batch_alter_table("room", schema=None) as batch_op:
80
+ batch_op.alter_column(
81
+ "muc_type", existing_type=sa.VARCHAR(length=21), nullable=True
82
+ )
83
+
84
+ with op.batch_alter_table("participant", schema=None) as batch_op:
85
+ batch_op.drop_constraint(batch_op.f("uq_participant_room_id"), type_="unique")
86
+ batch_op.alter_column(
87
+ "nickname_no_illegal", existing_type=sa.VARCHAR(), nullable=True
88
+ )
89
+ batch_op.alter_column("nickname", existing_type=sa.VARCHAR(), nullable=True)
90
+ batch_op.alter_column("resource", existing_type=sa.VARCHAR(), nullable=True)
91
+
92
+ with op.batch_alter_table("mam", schema=None) as batch_op:
93
+ batch_op.drop_constraint(batch_op.f("uq_mam_room_id"), type_="unique")
94
+
95
+ with op.batch_alter_table("hat", schema=None) as batch_op:
96
+ batch_op.drop_constraint(batch_op.f("uq_hat_title"), type_="unique")
97
+
98
+ with op.batch_alter_table("contact_sent", schema=None) as batch_op:
99
+ batch_op.drop_constraint(
100
+ batch_op.f("uq_contact_sent_contact_id"), type_="unique"
101
+ )
102
+
103
+ with op.batch_alter_table("contact", schema=None) as batch_op:
104
+ batch_op.drop_constraint(
105
+ batch_op.f("uq_contact_user_account_id"), type_="unique"
106
+ )
107
+
108
+ with op.batch_alter_table("bob", schema=None) as batch_op:
109
+ batch_op.drop_constraint(batch_op.f("uq_bob_sha_512"), type_="unique")
110
+ batch_op.drop_constraint(batch_op.f("uq_bob_sha_256"), type_="unique")
111
+ batch_op.drop_constraint(batch_op.f("uq_bob_sha_1"), type_="unique")
112
+
113
+ with op.batch_alter_table("avatar", schema=None) as batch_op:
114
+ batch_op.drop_constraint(batch_op.f("uq_avatar_legacy_id"), type_="unique")
115
+ batch_op.drop_constraint(batch_op.f("uq_avatar_hash"), type_="unique")
116
+ batch_op.create_unique_constraint("avatar_unique_legacy_id", ["legacy_id"])
117
+
118
+ # ### end Alembic commands ###
@@ -0,0 +1,74 @@
1
+ """Ditch hats table
2
+
3
+ Revision ID: 75a62b74b239
4
+ Revises: 0337c90c0b96
5
+ Create Date: 2025-05-02 14:24:26.141034
6
+
7
+ """
8
+
9
+ from typing import Sequence, Union
10
+
11
+ import sqlalchemy as sa
12
+ from alembic import op
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = "75a62b74b239"
16
+ down_revision: Union[str, None] = "0337c90c0b96"
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ # ### commands auto generated by Alembic - please adjust! ###
23
+ op.drop_table("participant_hats")
24
+ op.drop_table("hat")
25
+ with op.batch_alter_table("contact", schema=None) as batch_op:
26
+ batch_op.drop_constraint("uq_contact_user_account_id", type_="unique")
27
+ batch_op.create_unique_constraint(
28
+ batch_op.f("uq_contact_user_account_id"), ["user_account_id", "jid"]
29
+ )
30
+
31
+ with op.batch_alter_table("participant", schema=None) as batch_op:
32
+ batch_op.add_column(
33
+ sa.Column("hats", sa.JSON(), nullable=False, server_default="[]")
34
+ )
35
+
36
+ # ### end Alembic commands ###
37
+
38
+
39
+ def downgrade() -> None:
40
+ # ### commands auto generated by Alembic - please adjust! ###
41
+ with op.batch_alter_table("participant", schema=None) as batch_op:
42
+ batch_op.drop_column("hats")
43
+
44
+ with op.batch_alter_table("contact", schema=None) as batch_op:
45
+ batch_op.drop_constraint(
46
+ batch_op.f("uq_contact_user_account_id"), type_="unique"
47
+ )
48
+ batch_op.create_unique_constraint(
49
+ "uq_contact_user_account_id", ["user_account_id", "legacy_id"]
50
+ )
51
+
52
+ op.create_table(
53
+ "hat",
54
+ sa.Column("id", sa.INTEGER(), nullable=False),
55
+ sa.Column("title", sa.VARCHAR(), nullable=False),
56
+ sa.Column("uri", sa.VARCHAR(), nullable=False),
57
+ sa.PrimaryKeyConstraint("id"),
58
+ sa.UniqueConstraint("title", "uri", name="uq_hat_title"),
59
+ )
60
+ op.create_table(
61
+ "participant_hats",
62
+ sa.Column("participant_id", sa.INTEGER(), nullable=False),
63
+ sa.Column("hat_id", sa.INTEGER(), nullable=False),
64
+ sa.ForeignKeyConstraint(
65
+ ["hat_id"],
66
+ ["hat.id"],
67
+ ),
68
+ sa.ForeignKeyConstraint(
69
+ ["participant_id"],
70
+ ["participant.id"],
71
+ ),
72
+ sa.PrimaryKeyConstraint("participant_id", "hat_id"),
73
+ )
74
+ # ### end Alembic commands ###
slidge/db/avatar.py CHANGED
@@ -2,9 +2,7 @@ import asyncio
2
2
  import hashlib
3
3
  import io
4
4
  import logging
5
- import uuid
6
5
  from concurrent.futures import ThreadPoolExecutor
7
- from dataclasses import dataclass
8
6
  from http import HTTPStatus
9
7
  from pathlib import Path
10
8
  from typing import Optional
@@ -14,24 +12,42 @@ from multidict import CIMultiDictProxy
14
12
  from PIL.Image import Image
15
13
  from PIL.Image import open as open_image
16
14
  from sqlalchemy import select
17
- from sqlalchemy.orm.exc import DetachedInstanceError
18
15
 
19
- from slidge.core import config
20
- from slidge.db.models import Avatar
21
- from slidge.db.store import AvatarStore
22
- from slidge.util.types import URL, AvatarType
16
+ from ..core import config
17
+ from ..util.lock import NamedLockMixin
18
+ from ..util.types import Avatar as AvatarType
19
+ from .models import Avatar
20
+ from .store import AvatarStore
23
21
 
24
22
 
25
- @dataclass
26
23
  class CachedAvatar:
27
- pk: int
28
- filename: str
29
- hash: str
30
- height: int
31
- width: int
32
- root: Path
33
- etag: Optional[str] = None
34
- last_modified: Optional[str] = None
24
+ def __init__(self, stored: Avatar, root_dir: Path) -> None:
25
+ self.stored = stored
26
+ self._root = root_dir
27
+
28
+ @property
29
+ def pk(self) -> int | None:
30
+ return self.stored.id
31
+
32
+ @property
33
+ def hash(self) -> str:
34
+ return self.stored.hash
35
+
36
+ @property
37
+ def height(self) -> int:
38
+ return self.stored.height
39
+
40
+ @property
41
+ def width(self) -> int:
42
+ return self.stored.width
43
+
44
+ @property
45
+ def etag(self) -> str | None:
46
+ return self.stored.etag
47
+
48
+ @property
49
+ def last_modified(self) -> str | None:
50
+ return self.stored.last_modified
35
51
 
36
52
  @property
37
53
  def data(self):
@@ -39,40 +55,32 @@ class CachedAvatar:
39
55
 
40
56
  @property
41
57
  def path(self):
42
- return self.root / self.filename
43
-
44
- @staticmethod
45
- def from_store(stored: Avatar, root_dir: Path):
46
- return CachedAvatar(
47
- pk=stored.id,
48
- filename=stored.filename,
49
- hash=stored.hash,
50
- height=stored.height,
51
- width=stored.width,
52
- etag=stored.etag,
53
- root=root_dir,
54
- last_modified=stored.last_modified,
55
- )
58
+ return (self._root / self.hash).with_suffix(".png")
56
59
 
57
60
 
58
61
  class NotModified(Exception):
59
62
  pass
60
63
 
61
64
 
62
- class AvatarCache:
65
+ class AvatarCache(NamedLockMixin):
63
66
  dir: Path
64
67
  http: aiohttp.ClientSession
65
68
  store: AvatarStore
66
69
 
67
- def __init__(self):
70
+ def __init__(self) -> None:
68
71
  self._thread_pool = ThreadPoolExecutor(config.AVATAR_RESAMPLING_THREADS)
72
+ super().__init__()
73
+
74
+ def get(self, stored: Avatar) -> CachedAvatar:
75
+ return CachedAvatar(stored, self.dir)
69
76
 
70
- def set_dir(self, path: Path):
77
+ def set_dir(self, path: Path) -> None:
71
78
  self.dir = path
72
79
  self.dir.mkdir(exist_ok=True)
73
- with self.store.session():
74
- for stored in self.store.get_all():
75
- avatar = CachedAvatar.from_store(stored, root_dir=path)
80
+ log.debug("Checking avatar files")
81
+ with self.store.session(expire_on_commit=False) as orm:
82
+ for stored in orm.query(Avatar).all():
83
+ avatar = CachedAvatar(stored, path)
76
84
  if avatar.path.exists():
77
85
  continue
78
86
  log.warning(
@@ -80,14 +88,15 @@ class AvatarCache:
80
88
  avatar.hash,
81
89
  avatar.path,
82
90
  )
83
- self.store.delete_by_pk(stored.id)
91
+ orm.delete(stored)
92
+ orm.commit()
84
93
 
85
- def close(self):
94
+ def close(self) -> None:
86
95
  self._thread_pool.shutdown(cancel_futures=True)
87
96
 
88
- def __get_http_headers(self, cached: Optional[CachedAvatar | Avatar]):
97
+ def __get_http_headers(self, cached: Optional[CachedAvatar | Avatar] = None):
89
98
  headers = {}
90
- if cached and (self.dir / cached.filename).exists():
99
+ if cached and (self.dir / cached.hash).with_suffix(".png").exists():
91
100
  if last_modified := cached.last_modified:
92
101
  headers["If-Modified-Since"] = last_modified
93
102
  if etag := cached.etag:
@@ -113,105 +122,127 @@ class AvatarCache:
113
122
  async with self.http.head(url, headers=headers) as response:
114
123
  return response.status != HTTPStatus.NOT_MODIFIED
115
124
 
116
- async def url_modified(self, url: URL) -> bool:
117
- cached = self.store.get_by_url(url)
125
+ async def url_modified(self, url: str) -> bool:
126
+ with self.store.session() as orm:
127
+ cached = orm.query(Avatar).filter_by(url=url).one_or_none()
118
128
  if cached is None:
119
129
  return True
120
130
  headers = self.__get_http_headers(cached)
121
131
  return await self.__is_modified(url, headers)
122
132
 
123
- def get_by_pk(self, pk: int) -> CachedAvatar:
124
- stored = self.store.get_by_pk(pk)
125
- assert stored is not None
126
- return CachedAvatar.from_store(stored, self.dir)
127
-
128
133
  @staticmethod
129
134
  async def _get_image(avatar: AvatarType) -> Image:
130
- if isinstance(avatar, bytes):
131
- return open_image(io.BytesIO(avatar))
132
- elif isinstance(avatar, Path):
133
- return open_image(avatar)
135
+ if avatar.data is not None:
136
+ return open_image(io.BytesIO(avatar.data))
137
+ elif avatar.path is not None:
138
+ return open_image(avatar.path)
134
139
  raise TypeError("Avatar must be bytes or a Path", avatar)
135
140
 
136
141
  async def convert_or_get(self, avatar: AvatarType) -> CachedAvatar:
137
- if isinstance(avatar, (URL, str)):
138
- with self.store.session():
139
- stored = self.store.get_by_url(avatar)
140
- try:
141
- img, response_headers = await self.__download(
142
- avatar, self.__get_http_headers(stored)
143
- )
144
- except NotModified:
145
- assert stored is not None
146
- try:
147
- return CachedAvatar.from_store(stored, self.dir)
148
- except DetachedInstanceError:
149
- # This is an awful hack to prevent errors on startup under certain conditions,
150
- # because we basically misused SQLAlchemy pretty bad in slidge.db.store.EngineMixin.session().
151
- # cf https://codeberg.org/slidge/slidge/issues/36
152
- # and https://docs.sqlalchemy.org/en/20/orm/session_basics.html#session-faq-threadsafe
153
- # It may be related to threads as we only have reports of this for slidge-whatsapp and skidge
154
- # which are the only implementations that uses threads.
155
- # In any case, a proper fix implies a major refactoring in which we spawn and close SQLAlchemy
156
- # "ORM Session"s with a reasonable, well-thought lifetime, instead of how we do it now, where we
157
- # basically just brute-forced our way into having something usable but with poor performance.
158
- # Databases, asyncio, and concurrency in general are hard… :(
159
- # Oh, and getting rid of the convoluted mess that this giant method is would also probably
160
- # be a good idea.
161
- stored = self.store.get_by_url(avatar)
162
- assert stored is not None
163
- return CachedAvatar.from_store(stored, self.dir)
164
- else:
165
- img = await self._get_image(avatar)
166
- response_headers = None
167
- with self.store.session() as orm:
168
- resize = (size := config.AVATAR_SIZE) and any(x > size for x in img.size)
169
- if resize:
170
- await asyncio.get_event_loop().run_in_executor(
171
- self._thread_pool, img.thumbnail, (size, size)
142
+ if avatar.unique_id is not None:
143
+ with self.store.session() as orm:
144
+ stored = (
145
+ orm.query(Avatar)
146
+ .filter_by(legacy_id=str(avatar.unique_id))
147
+ .one_or_none()
172
148
  )
173
- log.debug("Resampled image to %s", img.size)
174
-
175
- filename = str(uuid.uuid1()) + ".png"
176
- file_path = self.dir / filename
177
-
178
- if (
179
- not resize
180
- and img.format == "PNG"
181
- and isinstance(avatar, (str, Path))
182
- and (path := Path(avatar))
183
- and path.exists()
184
- ):
185
- img_bytes = path.read_bytes()
186
- else:
187
- with io.BytesIO() as f:
188
- img.save(f, format="PNG")
189
- img_bytes = f.getvalue()
149
+ if stored is not None:
150
+ return self.get(stored)
151
+
152
+ if avatar.url is not None:
153
+ return await self.__convert_url(avatar)
154
+
155
+ return await self.convert(avatar, await self._get_image(avatar))
156
+
157
+ async def __convert_url(self, avatar: AvatarType) -> CachedAvatar:
158
+ assert avatar.url is not None
159
+ async with self.lock(avatar.unique_id or avatar.url):
160
+ with self.store.session() as orm:
161
+ if avatar.unique_id is None:
162
+ stored = orm.query(Avatar).filter_by(url=avatar.url).one_or_none()
163
+ else:
164
+ stored = (
165
+ orm.query(Avatar)
166
+ .filter_by(legacy_id=str(avatar.unique_id))
167
+ .one_or_none()
168
+ )
169
+ if stored is not None:
170
+ return self.get(stored)
190
171
 
191
- with file_path.open("wb") as file:
192
- file.write(img_bytes)
172
+ try:
173
+ img, response_headers = await self.__download(
174
+ avatar.url, self.__get_http_headers(stored)
175
+ )
176
+ except NotModified:
177
+ assert stored is not None
178
+ return self.get(stored)
193
179
 
194
- hash_ = hashlib.sha1(img_bytes).hexdigest()
180
+ return await self.convert(avatar, img, response_headers)
195
181
 
182
+ async def convert(
183
+ self,
184
+ avatar: AvatarType,
185
+ img: Image,
186
+ response_headers: CIMultiDictProxy[str] | None = None,
187
+ ) -> CachedAvatar:
188
+ resize = (size := config.AVATAR_SIZE) and any(x > size for x in img.size)
189
+ if resize:
190
+ await asyncio.get_event_loop().run_in_executor(
191
+ self._thread_pool, img.thumbnail, (size, size)
192
+ )
193
+ log.debug("Resampled image to %s", img.size)
194
+
195
+ if (
196
+ not resize
197
+ and img.format == "PNG"
198
+ and avatar.path is not None
199
+ and avatar.path.exists()
200
+ ):
201
+ img_bytes = avatar.path.read_bytes()
202
+ else:
203
+ with io.BytesIO() as f:
204
+ img.save(f, format="PNG")
205
+ img_bytes = f.getvalue()
206
+
207
+ hash_ = hashlib.sha1(img_bytes).hexdigest()
208
+ file_path = (self.dir / hash_).with_suffix(".png")
209
+ if file_path.exists():
210
+ log.warning("Overwriting %s", file_path)
211
+ with file_path.open("wb") as file:
212
+ file.write(img_bytes)
213
+
214
+ with self.store.session(expire_on_commit=False) as orm:
196
215
  stored = orm.execute(select(Avatar).where(Avatar.hash == hash_)).scalar()
197
216
 
198
217
  if stored is not None:
199
- return CachedAvatar.from_store(stored, self.dir)
200
-
201
- stored = Avatar(
202
- filename=filename,
203
- hash=hash_,
204
- height=img.height,
205
- width=img.width,
206
- url=avatar if isinstance(avatar, (URL, str)) else None,
207
- )
208
- if response_headers:
209
- stored.etag = response_headers.get("etag")
210
- stored.last_modified = response_headers.get("last-modified")
218
+ if avatar.unique_id is not None:
219
+ if str(avatar.unique_id) != stored.legacy_id:
220
+ log.warning(
221
+ "Updating the 'unique' ID of an avatar, was '%s', is now '%s'",
222
+ stored.legacy_id,
223
+ avatar.unique_id,
224
+ )
225
+ stored.legacy_id = str(avatar.unique_id)
226
+ orm.add(stored)
227
+ orm.commit()
228
+
229
+ return self.get(stored)
230
+
231
+ stored = Avatar(
232
+ hash=hash_,
233
+ height=img.height,
234
+ width=img.width,
235
+ url=avatar.url,
236
+ legacy_id=avatar.unique_id,
237
+ )
238
+ if response_headers:
239
+ stored.etag = response_headers.get("etag")
240
+ stored.last_modified = response_headers.get("last-modified")
211
241
 
242
+ with self.store.session(expire_on_commit=False) as orm:
212
243
  orm.add(stored)
213
244
  orm.commit()
214
- return CachedAvatar.from_store(stored, self.dir)
245
+ return self.get(stored)
215
246
 
216
247
 
217
248
  avatar_cache = AvatarCache()
slidge/db/meta.py CHANGED
@@ -1,10 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
- from typing import Union
4
+ from typing import Any, Union
5
5
 
6
6
  import sqlalchemy as sa
7
7
  from slixmpp import JID
8
+ from sqlalchemy import Dialect
8
9
 
9
10
 
10
11
  class JIDType(sa.TypeDecorator[JID]):
@@ -28,7 +29,11 @@ class JIDType(sa.TypeDecorator[JID]):
28
29
  return JID(value)
29
30
 
30
31
 
31
- class JSONEncodedDict(sa.TypeDecorator):
32
+ JSONSerializableTypes = Union[str, float, None, "JSONSerializable"]
33
+ JSONSerializable = dict[str, JSONSerializableTypes]
34
+
35
+
36
+ class JSONEncodedDict(sa.TypeDecorator[JSONSerializable]):
32
37
  """
33
38
  Custom SQLAlchemy type for dictionaries stored as JSON
34
39
 
@@ -40,33 +45,39 @@ class JSONEncodedDict(sa.TypeDecorator):
40
45
 
41
46
  cache_ok = True
42
47
 
43
- def process_bind_param(self, value, dialect):
44
- if value is not None:
45
- value = json.dumps(value)
48
+ def process_bind_param(
49
+ self, value: JSONSerializable | None, dialect: Dialect
50
+ ) -> str | None:
51
+ if value is None:
52
+ return None
53
+ return json.dumps(value)
46
54
 
47
- return value
55
+ def process_result_value(
56
+ self, value: Any | None, dialect: Dialect
57
+ ) -> JSONSerializable | None:
58
+ if value is None:
59
+ return None
60
+ return json.loads(value) # type:ignore
48
61
 
49
- def process_result_value(self, value, dialect):
50
- if value is not None:
51
- value = json.loads(value)
52
- return value
53
62
 
63
+ class Base(sa.orm.DeclarativeBase):
64
+ type_annotation_map = {JSONSerializable: JSONEncodedDict, JID: JIDType}
54
65
 
55
- JSONSerializableTypes = Union[str, float, None, "JSONSerializable"]
56
- JSONSerializable = dict[str, JSONSerializableTypes]
57
66
 
67
+ Base.metadata.naming_convention = {
68
+ "ix": "ix_%(column_0_label)s",
69
+ "uq": "uq_%(table_name)s_%(column_0_name)s",
70
+ "ck": "ck_%(table_name)s_`%(constraint_name)s`",
71
+ "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
72
+ "pk": "pk_%(table_name)s",
73
+ }
58
74
 
59
- class Base(sa.orm.DeclarativeBase):
60
- type_annotation_map = {JSONSerializable: JSONEncodedDict, JID: JIDType}
61
- naming_convention = {
62
- "ix": "ix_%(column_0_label)s",
63
- "uq": "uq_%(table_name)s_%(column_0_name)s",
64
- "ck": "ck_%(table_name)s_`%(constraint_name)s`",
65
- "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
66
- "pk": "pk_%(table_name)s",
67
- }
68
75
 
76
+ def get_engine(path: str, echo: bool = False) -> sa.Engine:
77
+ from sqlalchemy import log as sqlalchemy_log
69
78
 
70
- def get_engine(path: str) -> sa.Engine:
71
79
  engine = sa.create_engine(path)
80
+ if echo:
81
+ sqlalchemy_log._add_default_handler = lambda x: None # type:ignore
82
+ engine.echo = True
72
83
  return engine