belgie-alchemy 0.1.0__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.
@@ -0,0 +1,33 @@
1
+ """SQLAlchemy 2.0 building blocks for database models.
2
+
3
+ This module provides opinionated defaults and utilities for SQLAlchemy:
4
+ - Base: Declarative base with dataclass mapping and sensible defaults
5
+ - Mixins: PrimaryKeyMixin (UUID), TimestampMixin (created/updated/deleted)
6
+ - Types: DateTimeUTC (timezone-aware datetime storage)
7
+
8
+ Usage:
9
+ from belgie_alchemy import Base, PrimaryKeyMixin, TimestampMixin, DateTimeUTC
10
+
11
+ class MyModel(Base, PrimaryKeyMixin, TimestampMixin):
12
+ __tablename__ = "my_models"
13
+
14
+ name: Mapped[str]
15
+ created_on: Mapped[datetime] = mapped_column(DateTimeUTC)
16
+
17
+ For complete auth model examples, see examples/alchemy/auth_models.py
18
+ """
19
+
20
+ from belgie_alchemy.adapter import AlchemyAdapter
21
+ from belgie_alchemy.base import Base
22
+ from belgie_alchemy.mixins import PrimaryKeyMixin, TimestampMixin
23
+ from belgie_alchemy.settings import DatabaseSettings
24
+ from belgie_alchemy.types import DateTimeUTC
25
+
26
+ __all__ = [
27
+ "AlchemyAdapter",
28
+ "Base",
29
+ "DatabaseSettings",
30
+ "DateTimeUTC",
31
+ "PrimaryKeyMixin",
32
+ "TimestampMixin",
33
+ ]
File without changes
File without changes
@@ -0,0 +1,493 @@
1
+ from datetime import UTC, datetime, timedelta
2
+ from uuid import uuid4
3
+
4
+ import pytest
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+ from belgie_alchemy import AlchemyAdapter
8
+ from belgie_alchemy.__tests__.fixtures.models import Account, OAuthState, Session, User
9
+
10
+
11
+ @pytest.fixture
12
+ def adapter(alchemy_session: AsyncSession) -> AlchemyAdapter: # noqa: ARG001
13
+ return AlchemyAdapter(
14
+ user=User,
15
+ account=Account,
16
+ session=Session,
17
+ oauth_state=OAuthState,
18
+ )
19
+
20
+
21
+ @pytest.mark.asyncio
22
+ async def test_create_user(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
23
+ user = await adapter.create_user(
24
+ alchemy_session,
25
+ email="test@example.com",
26
+ name="Test User",
27
+ email_verified=True,
28
+ )
29
+
30
+ assert user.email == "test@example.com"
31
+ assert user.name == "Test User"
32
+ assert user.email_verified is True
33
+ assert user.id is not None
34
+ assert user.created_at is not None
35
+
36
+
37
+ @pytest.mark.asyncio
38
+ async def test_get_user_by_id(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
39
+ created_user = await adapter.create_user(
40
+ alchemy_session,
41
+ email="test@example.com",
42
+ name="Test User",
43
+ )
44
+
45
+ found_user = await adapter.get_user_by_id(alchemy_session, created_user.id)
46
+
47
+ assert found_user is not None
48
+ assert found_user.id == created_user.id
49
+ assert found_user.email == "test@example.com"
50
+
51
+
52
+ @pytest.mark.asyncio
53
+ async def test_get_user_by_id_not_found(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
54
+ user = await adapter.get_user_by_id(alchemy_session, uuid4())
55
+ assert user is None
56
+
57
+
58
+ @pytest.mark.asyncio
59
+ async def test_get_user_by_email(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
60
+ await adapter.create_user(
61
+ alchemy_session,
62
+ email="test@example.com",
63
+ name="Test User",
64
+ )
65
+
66
+ user = await adapter.get_user_by_email(alchemy_session, "test@example.com")
67
+
68
+ assert user is not None
69
+ assert user.email == "test@example.com"
70
+
71
+
72
+ @pytest.mark.asyncio
73
+ async def test_get_user_by_email_not_found(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
74
+ user = await adapter.get_user_by_email(alchemy_session, "nonexistent@example.com")
75
+ assert user is None
76
+
77
+
78
+ @pytest.mark.asyncio
79
+ async def test_update_user(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
80
+ user = await adapter.create_user(
81
+ alchemy_session,
82
+ email="test@example.com",
83
+ name="Test User",
84
+ )
85
+
86
+ updated_user = await adapter.update_user(
87
+ alchemy_session,
88
+ user.id,
89
+ name="Updated Name",
90
+ email_verified=True,
91
+ )
92
+
93
+ assert updated_user is not None
94
+ assert updated_user.name == "Updated Name"
95
+ assert updated_user.email_verified is True
96
+
97
+
98
+ @pytest.mark.asyncio
99
+ async def test_update_user_not_found(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
100
+ updated_user = await adapter.update_user(
101
+ alchemy_session,
102
+ uuid4(),
103
+ name="Updated Name",
104
+ )
105
+ assert updated_user is None
106
+
107
+
108
+ @pytest.mark.asyncio
109
+ async def test_create_account(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
110
+ user = await adapter.create_user(
111
+ alchemy_session,
112
+ email="test@example.com",
113
+ )
114
+
115
+ account = await adapter.create_account(
116
+ alchemy_session,
117
+ user_id=user.id,
118
+ provider="google",
119
+ provider_account_id="12345",
120
+ access_token="token",
121
+ refresh_token="refresh",
122
+ expires_at=datetime.now(UTC) + timedelta(hours=1),
123
+ token_type="Bearer",
124
+ scope="openid email",
125
+ id_token="id_token",
126
+ )
127
+
128
+ assert account.user_id == user.id
129
+ assert account.provider == "google"
130
+ assert account.provider_account_id == "12345"
131
+ assert account.access_token == "token" # noqa: S105
132
+ assert account.refresh_token == "refresh" # noqa: S105
133
+ assert account.token_type == "Bearer" # noqa: S105
134
+
135
+
136
+ @pytest.mark.asyncio
137
+ async def test_get_account(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
138
+ user = await adapter.create_user(
139
+ alchemy_session,
140
+ email="test@example.com",
141
+ )
142
+
143
+ await adapter.create_account(
144
+ alchemy_session,
145
+ user_id=user.id,
146
+ provider="google",
147
+ provider_account_id="12345",
148
+ )
149
+
150
+ account = await adapter.get_account(alchemy_session, "google", "12345")
151
+
152
+ assert account is not None
153
+ assert account.provider == "google"
154
+ assert account.provider_account_id == "12345"
155
+
156
+
157
+ @pytest.mark.asyncio
158
+ async def test_get_account_not_found(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
159
+ account = await adapter.get_account(alchemy_session, "google", "nonexistent")
160
+ assert account is None
161
+
162
+
163
+ @pytest.mark.asyncio
164
+ async def test_create_session(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
165
+ user = await adapter.create_user(
166
+ alchemy_session,
167
+ email="test@example.com",
168
+ )
169
+
170
+ expires_at = datetime.now(UTC) + timedelta(days=7)
171
+ session = await adapter.create_session(
172
+ alchemy_session,
173
+ user_id=user.id,
174
+ expires_at=expires_at,
175
+ ip_address="127.0.0.1",
176
+ user_agent="Test Agent",
177
+ )
178
+
179
+ assert session.user_id == user.id
180
+ assert session.expires_at.replace(tzinfo=UTC) == expires_at
181
+ assert session.ip_address == "127.0.0.1"
182
+ assert session.user_agent == "Test Agent"
183
+
184
+
185
+ @pytest.mark.asyncio
186
+ async def test_get_session(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
187
+ user = await adapter.create_user(
188
+ alchemy_session,
189
+ email="test@example.com",
190
+ )
191
+
192
+ created_session = await adapter.create_session(
193
+ alchemy_session,
194
+ user_id=user.id,
195
+ expires_at=datetime.now(UTC) + timedelta(days=7),
196
+ )
197
+
198
+ found_session = await adapter.get_session(alchemy_session, created_session.id)
199
+
200
+ assert found_session is not None
201
+ assert found_session.id == created_session.id
202
+
203
+
204
+ @pytest.mark.asyncio
205
+ async def test_get_session_not_found(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
206
+ session = await adapter.get_session(alchemy_session, uuid4())
207
+ assert session is None
208
+
209
+
210
+ @pytest.mark.asyncio
211
+ async def test_update_session(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
212
+ user = await adapter.create_user(
213
+ alchemy_session,
214
+ email="test@example.com",
215
+ )
216
+
217
+ session = await adapter.create_session(
218
+ alchemy_session,
219
+ user_id=user.id,
220
+ expires_at=datetime.now(UTC) + timedelta(days=7),
221
+ )
222
+
223
+ new_expires = datetime.now(UTC) + timedelta(days=14)
224
+ updated_session = await adapter.update_session(
225
+ alchemy_session,
226
+ session.id,
227
+ expires_at=new_expires,
228
+ ip_address="192.168.1.1",
229
+ )
230
+
231
+ assert updated_session is not None
232
+ assert updated_session.expires_at.replace(tzinfo=UTC) == new_expires
233
+ assert updated_session.ip_address == "192.168.1.1"
234
+
235
+
236
+ @pytest.mark.asyncio
237
+ async def test_update_session_not_found(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
238
+ updated_session = await adapter.update_session(
239
+ alchemy_session,
240
+ uuid4(),
241
+ expires_at=datetime.now(UTC) + timedelta(days=7),
242
+ )
243
+ assert updated_session is None
244
+
245
+
246
+ @pytest.mark.asyncio
247
+ async def test_delete_session(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
248
+ user = await adapter.create_user(
249
+ alchemy_session,
250
+ email="test@example.com",
251
+ )
252
+
253
+ session = await adapter.create_session(
254
+ alchemy_session,
255
+ user_id=user.id,
256
+ expires_at=datetime.now(UTC) + timedelta(days=7),
257
+ )
258
+
259
+ deleted = await adapter.delete_session(alchemy_session, session.id)
260
+ assert deleted is True
261
+
262
+ found = await adapter.get_session(alchemy_session, session.id)
263
+ assert found is None
264
+
265
+
266
+ @pytest.mark.asyncio
267
+ async def test_delete_session_not_found(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
268
+ deleted = await adapter.delete_session(alchemy_session, uuid4())
269
+ assert deleted is False
270
+
271
+
272
+ @pytest.mark.asyncio
273
+ async def test_delete_expired_sessions(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
274
+ user = await adapter.create_user(
275
+ alchemy_session,
276
+ email="test@example.com",
277
+ )
278
+
279
+ await adapter.create_session(
280
+ alchemy_session,
281
+ user_id=user.id,
282
+ expires_at=datetime.now(UTC) - timedelta(days=1),
283
+ )
284
+
285
+ await adapter.create_session(
286
+ alchemy_session,
287
+ user_id=user.id,
288
+ expires_at=datetime.now(UTC) + timedelta(days=7),
289
+ )
290
+
291
+ count = await adapter.delete_expired_sessions(alchemy_session)
292
+ assert count == 1
293
+
294
+
295
+ @pytest.mark.asyncio
296
+ async def test_create_oauth_state(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
297
+ expires_at = datetime.now(UTC) + timedelta(minutes=10)
298
+ oauth_state = await adapter.create_oauth_state(
299
+ alchemy_session,
300
+ state="random_state_123",
301
+ expires_at=expires_at,
302
+ code_verifier="verifier_abc",
303
+ redirect_url="/dashboard",
304
+ )
305
+
306
+ assert oauth_state.state == "random_state_123"
307
+ assert oauth_state.code_verifier == "verifier_abc"
308
+ assert oauth_state.redirect_url == "/dashboard"
309
+ assert oauth_state.expires_at.replace(tzinfo=UTC) == expires_at
310
+
311
+
312
+ @pytest.mark.asyncio
313
+ async def test_get_oauth_state(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
314
+ await adapter.create_oauth_state(
315
+ alchemy_session,
316
+ state="random_state_123",
317
+ expires_at=datetime.now(UTC) + timedelta(minutes=10),
318
+ )
319
+
320
+ oauth_state = await adapter.get_oauth_state(alchemy_session, "random_state_123")
321
+
322
+ assert oauth_state is not None
323
+ assert oauth_state.state == "random_state_123"
324
+
325
+
326
+ @pytest.mark.asyncio
327
+ async def test_get_oauth_state_not_found(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
328
+ oauth_state = await adapter.get_oauth_state(alchemy_session, "nonexistent")
329
+ assert oauth_state is None
330
+
331
+
332
+ @pytest.mark.asyncio
333
+ async def test_delete_oauth_state(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
334
+ await adapter.create_oauth_state(
335
+ alchemy_session,
336
+ state="random_state_123",
337
+ expires_at=datetime.now(UTC) + timedelta(minutes=10),
338
+ )
339
+
340
+ deleted = await adapter.delete_oauth_state(alchemy_session, "random_state_123")
341
+ assert deleted is True
342
+
343
+ found = await adapter.get_oauth_state(alchemy_session, "random_state_123")
344
+ assert found is None
345
+
346
+
347
+ @pytest.mark.asyncio
348
+ async def test_delete_oauth_state_not_found(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
349
+ deleted = await adapter.delete_oauth_state(alchemy_session, "nonexistent")
350
+ assert deleted is False
351
+
352
+
353
+ @pytest.mark.asyncio
354
+ async def test_user_with_custom_fields(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
355
+ user_data = User(
356
+ email="custom@example.com",
357
+ email_verified=True,
358
+ name="Custom User",
359
+ image=None,
360
+ custom_field="custom value",
361
+ )
362
+ alchemy_session.add(user_data)
363
+ await alchemy_session.commit()
364
+
365
+ found = await adapter.get_user_by_email(alchemy_session, "custom@example.com")
366
+ assert found is not None
367
+ assert found.custom_field == "custom value"
368
+
369
+
370
+ @pytest.mark.asyncio
371
+ async def test_delete_user_deletes_user(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
372
+ user = await adapter.create_user(alchemy_session, email="delete@example.com", name="Delete User")
373
+
374
+ deleted = await adapter.delete_user(alchemy_session, user.id)
375
+
376
+ assert deleted is True
377
+ assert await adapter.get_user_by_id(alchemy_session, user.id) is None
378
+
379
+
380
+ @pytest.mark.asyncio
381
+ async def test_delete_user_deletes_sessions(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
382
+ user = await adapter.create_user(alchemy_session, email="delete@example.com", name="Delete User")
383
+
384
+ expires_at = datetime.now(UTC) + timedelta(days=1)
385
+ session1 = await adapter.create_session(alchemy_session, user.id, expires_at.replace(tzinfo=None))
386
+ session2 = await adapter.create_session(alchemy_session, user.id, expires_at.replace(tzinfo=None))
387
+
388
+ await adapter.delete_user(alchemy_session, user.id)
389
+
390
+ assert await adapter.get_session(alchemy_session, session1.id) is None
391
+ assert await adapter.get_session(alchemy_session, session2.id) is None
392
+
393
+
394
+ @pytest.mark.asyncio
395
+ async def test_delete_user_deletes_accounts(adapter: AlchemyAdapter, alchemy_session: AsyncSession) -> None:
396
+ user = await adapter.create_user(alchemy_session, email="delete@example.com", name="Delete User")
397
+
398
+ await adapter.create_account(
399
+ alchemy_session,
400
+ user.id,
401
+ "google",
402
+ "google-123",
403
+ access_token="token123",
404
+ )
405
+
406
+ await adapter.delete_user(alchemy_session, user.id)
407
+
408
+ assert await adapter.get_account(alchemy_session, "google", "google-123") is None
409
+
410
+
411
+ @pytest.mark.asyncio
412
+ async def test_delete_user_deletes_all_related_data(
413
+ adapter: AlchemyAdapter,
414
+ alchemy_session: AsyncSession,
415
+ ) -> None:
416
+ user = await adapter.create_user(alchemy_session, email="delete@example.com", name="Delete User")
417
+
418
+ expires_at = datetime.now(UTC) + timedelta(days=1)
419
+ session1 = await adapter.create_session(alchemy_session, user.id, expires_at.replace(tzinfo=None))
420
+ session2 = await adapter.create_session(alchemy_session, user.id, expires_at.replace(tzinfo=None))
421
+
422
+ await adapter.create_account(
423
+ alchemy_session,
424
+ user.id,
425
+ "google",
426
+ "google-123",
427
+ access_token="token123",
428
+ )
429
+ await adapter.create_account(
430
+ alchemy_session,
431
+ user.id,
432
+ "github",
433
+ "github-456",
434
+ access_token="token456",
435
+ )
436
+
437
+ deleted = await adapter.delete_user(alchemy_session, user.id)
438
+
439
+ assert deleted is True
440
+ assert await adapter.get_user_by_id(alchemy_session, user.id) is None
441
+ assert await adapter.get_session(alchemy_session, session1.id) is None
442
+ assert await adapter.get_session(alchemy_session, session2.id) is None
443
+ assert await adapter.get_account(alchemy_session, "google", "google-123") is None
444
+ assert await adapter.get_account(alchemy_session, "github", "github-456") is None
445
+
446
+
447
+ @pytest.mark.asyncio
448
+ async def test_delete_user_returns_false_if_user_not_found(
449
+ adapter: AlchemyAdapter,
450
+ alchemy_session: AsyncSession,
451
+ ) -> None:
452
+ fake_user_id = uuid4()
453
+ deleted = await adapter.delete_user(alchemy_session, fake_user_id)
454
+
455
+ assert deleted is False
456
+
457
+
458
+ @pytest.mark.asyncio
459
+ async def test_delete_user_only_deletes_target_users_data(
460
+ adapter: AlchemyAdapter,
461
+ alchemy_session: AsyncSession,
462
+ ) -> None:
463
+ user1 = await adapter.create_user(alchemy_session, email="user1@example.com", name="User 1")
464
+ user2 = await adapter.create_user(alchemy_session, email="user2@example.com", name="User 2")
465
+
466
+ expires_at = datetime.now(UTC) + timedelta(days=1)
467
+ session1 = await adapter.create_session(alchemy_session, user1.id, expires_at.replace(tzinfo=None))
468
+ session2 = await adapter.create_session(alchemy_session, user2.id, expires_at.replace(tzinfo=None))
469
+
470
+ await adapter.create_account(
471
+ alchemy_session,
472
+ user1.id,
473
+ "google",
474
+ "google-user1",
475
+ access_token="token1",
476
+ )
477
+ await adapter.create_account(
478
+ alchemy_session,
479
+ user2.id,
480
+ "google",
481
+ "google-user2",
482
+ access_token="token2",
483
+ )
484
+
485
+ await adapter.delete_user(alchemy_session, user1.id)
486
+
487
+ assert await adapter.get_user_by_id(alchemy_session, user1.id) is None
488
+ assert await adapter.get_session(alchemy_session, session1.id) is None
489
+ assert await adapter.get_account(alchemy_session, "google", "google-user1") is None
490
+
491
+ assert await adapter.get_user_by_id(alchemy_session, user2.id) is not None
492
+ assert await adapter.get_session(alchemy_session, session2.id) is not None
493
+ assert await adapter.get_account(alchemy_session, "google", "google-user2") is not None
File without changes
@@ -0,0 +1,91 @@
1
+ from datetime import UTC, datetime, timedelta
2
+
3
+ import pytest
4
+ from sqlalchemy import select
5
+ from sqlalchemy.exc import IntegrityError
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from belgie_alchemy.__tests__.fixtures.models import Account, OAuthState, Session, User
9
+
10
+
11
+ def test_user_model_structure() -> None:
12
+ """Verify User model demonstrates proper structure."""
13
+ assert User.__tablename__ == "users"
14
+ assert not getattr(User, "__abstract__", False)
15
+ assert hasattr(User, "email")
16
+ assert hasattr(User, "scopes")
17
+
18
+
19
+ def test_user_has_scopes_field() -> None:
20
+ """Verify User has scopes field that accepts list of strings."""
21
+ user = User(email="test@example.com")
22
+ user.scopes = ["read", "write"]
23
+ assert user.scopes == ["read", "write"]
24
+
25
+
26
+ def test_user_relationships_defined() -> None:
27
+ """Verify User has bidirectional relationships defined."""
28
+ assert hasattr(User, "accounts")
29
+ assert hasattr(User, "sessions")
30
+ assert hasattr(User, "oauth_states")
31
+
32
+
33
+ @pytest.mark.asyncio
34
+ async def test_account_unique_constraint(alchemy_session: AsyncSession) -> None:
35
+ user = User(email="auth@example.com")
36
+ alchemy_session.add(user)
37
+ await alchemy_session.commit()
38
+
39
+ account = Account(
40
+ user_id=user.id,
41
+ provider="google",
42
+ provider_account_id="abc",
43
+ )
44
+ alchemy_session.add(account)
45
+ await alchemy_session.commit()
46
+
47
+ duplicate = Account(
48
+ user_id=user.id,
49
+ provider="google",
50
+ provider_account_id="abc",
51
+ )
52
+ alchemy_session.add(duplicate)
53
+ with pytest.raises(IntegrityError):
54
+ await alchemy_session.commit()
55
+ await alchemy_session.rollback()
56
+
57
+
58
+ @pytest.mark.asyncio
59
+ async def test_session_relationship(alchemy_session: AsyncSession) -> None:
60
+ user = User(email="session@example.com")
61
+ alchemy_session.add(user)
62
+ await alchemy_session.commit()
63
+
64
+ session = Session(
65
+ user_id=user.id,
66
+ expires_at=datetime.now(UTC) + timedelta(days=1),
67
+ )
68
+ alchemy_session.add(session)
69
+ await alchemy_session.commit()
70
+
71
+ refreshed = await alchemy_session.get(User, user.id)
72
+ assert refreshed is not None
73
+ await alchemy_session.refresh(refreshed, attribute_names=["sessions"])
74
+ assert len(refreshed.sessions) == 1
75
+
76
+
77
+ @pytest.mark.asyncio
78
+ async def test_oauth_state_optional_user(alchemy_session: AsyncSession) -> None:
79
+ state = OAuthState(
80
+ state="abc",
81
+ code_verifier=None,
82
+ redirect_url=None,
83
+ expires_at=datetime.now(UTC) + timedelta(minutes=5),
84
+ user_id=None,
85
+ )
86
+ alchemy_session.add(state)
87
+ await alchemy_session.commit()
88
+
89
+ rows = await alchemy_session.execute(select(OAuthState))
90
+ stored = rows.scalar_one()
91
+ assert stored.user is None
File without changes