py-auth-sqlalchemy 0.0.1__tar.gz → 0.0.2__tar.gz
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.
- py_auth_sqlalchemy-0.0.2/PKG-INFO +22 -0
- py_auth_sqlalchemy-0.0.2/README.md +0 -0
- {py_auth_sqlalchemy-0.0.1 → py_auth_sqlalchemy-0.0.2}/pyproject.toml +3 -4
- py_auth_sqlalchemy-0.0.2/src/py_auth_sqlalchemy/__init__.py +8 -0
- py_auth_sqlalchemy-0.0.1/src/py_auth_sqlalchemy/utils.py → py_auth_sqlalchemy-0.0.2/src/py_auth_sqlalchemy/_utils.py +9 -10
- py_auth_sqlalchemy-0.0.2/src/py_auth_sqlalchemy/core.py +350 -0
- py_auth_sqlalchemy-0.0.2/src/py_auth_sqlalchemy/utc_datetime.py +58 -0
- py_auth_sqlalchemy-0.0.2/src/py_auth_sqlalchemy.egg-info/PKG-INFO +22 -0
- {py_auth_sqlalchemy-0.0.1 → py_auth_sqlalchemy-0.0.2}/src/py_auth_sqlalchemy.egg-info/SOURCES.txt +2 -1
- {py_auth_sqlalchemy-0.0.1 → py_auth_sqlalchemy-0.0.2}/src/py_auth_sqlalchemy.egg-info/requires.txt +1 -1
- py_auth_sqlalchemy-0.0.1/PKG-INFO +0 -330
- py_auth_sqlalchemy-0.0.1/README.md +0 -306
- py_auth_sqlalchemy-0.0.1/src/py_auth_sqlalchemy/__init__.py +0 -10
- py_auth_sqlalchemy-0.0.1/src/py_auth_sqlalchemy/core.py +0 -111
- py_auth_sqlalchemy-0.0.1/src/py_auth_sqlalchemy.egg-info/PKG-INFO +0 -330
- {py_auth_sqlalchemy-0.0.1 → py_auth_sqlalchemy-0.0.2}/LICENSE +0 -0
- {py_auth_sqlalchemy-0.0.1 → py_auth_sqlalchemy-0.0.2}/setup.cfg +0 -0
- {py_auth_sqlalchemy-0.0.1 → py_auth_sqlalchemy-0.0.2}/src/py_auth_sqlalchemy/py.typed +0 -0
- {py_auth_sqlalchemy-0.0.1 → py_auth_sqlalchemy-0.0.2}/src/py_auth_sqlalchemy.egg-info/dependency_links.txt +0 -0
- {py_auth_sqlalchemy-0.0.1 → py_auth_sqlalchemy-0.0.2}/src/py_auth_sqlalchemy.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py-auth-sqlalchemy
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: High-performance, async SQLAlchemy adapter for py-auth-core.
|
|
5
|
+
Author-email: Olatunji Jamaldeen Omotoyosi <jamaldeen.o@yahoo.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jamaldeen09/py-auth
|
|
8
|
+
Project-URL: Repository, https://github.com/jamaldeen09/py-auth
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: sqlalchemy>=2.0.0
|
|
20
|
+
Requires-Dist: greenlet>=3.0.0
|
|
21
|
+
Requires-Dist: py-auth-core>=0.0.3
|
|
22
|
+
Dynamic: license-file
|
|
File without changes
|
|
@@ -4,16 +4,15 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "py-auth-sqlalchemy"
|
|
7
|
-
version = "0.0.
|
|
7
|
+
version = "0.0.2"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name = "Olatunji Jamaldeen Omotoyosi", email = "jamaldeen.o@yahoo.com" },
|
|
10
10
|
]
|
|
11
11
|
description = "High-performance, async SQLAlchemy adapter for py-auth-core."
|
|
12
12
|
readme = "README.md"
|
|
13
|
-
requires-python = ">=3.
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Programming Language :: Python :: 3",
|
|
16
|
-
"Programming Language :: Python :: 3.9",
|
|
17
16
|
"Programming Language :: Python :: 3.10",
|
|
18
17
|
"Programming Language :: Python :: 3.11",
|
|
19
18
|
"Programming Language :: Python :: 3.12",
|
|
@@ -27,7 +26,7 @@ license-files = ["LICENSE"]
|
|
|
27
26
|
dependencies = [
|
|
28
27
|
"sqlalchemy>=2.0.0",
|
|
29
28
|
"greenlet>=3.0.0",
|
|
30
|
-
"py-auth-core>=0.0.
|
|
29
|
+
"py-auth-core>=0.0.3"
|
|
31
30
|
]
|
|
32
31
|
|
|
33
32
|
[project.urls]
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""Utility functions for engine validation, model validation, and database error handling."""
|
|
2
|
+
|
|
1
3
|
from contextlib import asynccontextmanager
|
|
2
4
|
from typing import Any
|
|
3
5
|
|
|
@@ -12,7 +14,6 @@ from sqlalchemy import inspect
|
|
|
12
14
|
from sqlalchemy.exc import IntegrityError, NoResultFound
|
|
13
15
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
14
16
|
|
|
15
|
-
|
|
16
17
|
def validate_async_engine(engine: object) -> AsyncEngine:
|
|
17
18
|
"""Validate that the engine is an asynchronous SQLAlchemy AsyncEngine
|
|
18
19
|
and uses a supported asynchronous database driver.
|
|
@@ -23,7 +24,6 @@ def validate_async_engine(engine: object) -> AsyncEngine:
|
|
|
23
24
|
"'AsyncEngine' (created via create_async_engine)."
|
|
24
25
|
)
|
|
25
26
|
|
|
26
|
-
# Inspect the driver prefix from the engine's URL object
|
|
27
27
|
supported_drivers = ("asyncpg", "aiomysql", "aiosqlite")
|
|
28
28
|
driver = engine.url.get_driver_name()
|
|
29
29
|
|
|
@@ -37,7 +37,7 @@ def validate_async_engine(engine: object) -> AsyncEngine:
|
|
|
37
37
|
|
|
38
38
|
def validate_sqlalchemy_model(
|
|
39
39
|
model: type[Any],
|
|
40
|
-
|
|
40
|
+
required_columns: set[str],
|
|
41
41
|
model_name: str | None = None,
|
|
42
42
|
) -> type[Any]:
|
|
43
43
|
"""Validate that the provided class is a valid SQLAlchemy declarative model
|
|
@@ -51,20 +51,19 @@ def validate_sqlalchemy_model(
|
|
|
51
51
|
|
|
52
52
|
try:
|
|
53
53
|
mapper = inspect(model)
|
|
54
|
-
|
|
54
|
+
column_names = {c.key for c in mapper.columns}
|
|
55
55
|
except Exception as e:
|
|
56
56
|
raise AdapterError(
|
|
57
57
|
f"Provided '{display_name}' must be a valid SQLAlchemy model class: {e}"
|
|
58
58
|
)
|
|
59
59
|
|
|
60
|
-
if not
|
|
61
|
-
missing = sorted(
|
|
60
|
+
if not required_columns.issubset(column_names):
|
|
61
|
+
missing = sorted(required_columns - column_names)
|
|
62
62
|
raise AdapterError(
|
|
63
63
|
f"Custom {display_name} model is missing required py-auth columns: {missing}. "
|
|
64
64
|
"Extra custom columns are allowed, but these base columns are mandatory."
|
|
65
65
|
)
|
|
66
|
-
return model
|
|
67
|
-
|
|
66
|
+
return model
|
|
68
67
|
|
|
69
68
|
@asynccontextmanager
|
|
70
69
|
async def handle_db_errors(operation: str):
|
|
@@ -84,7 +83,7 @@ async def handle_db_errors(operation: str):
|
|
|
84
83
|
f"Failed to complete operation '{operation}': a record with this unique value already exists."
|
|
85
84
|
) from e
|
|
86
85
|
raise PyAuthError(
|
|
87
|
-
f"Database integrity error occurred in operation '{operation}'
|
|
86
|
+
f"Database integrity error occurred in operation '{operation}'"
|
|
88
87
|
) from e
|
|
89
88
|
|
|
90
89
|
except NoResultFound as e:
|
|
@@ -96,5 +95,5 @@ async def handle_db_errors(operation: str):
|
|
|
96
95
|
raise
|
|
97
96
|
except Exception as e:
|
|
98
97
|
raise PyAuthError(
|
|
99
|
-
f"An unexpected database error occurred in operation '{operation}'
|
|
98
|
+
f"An unexpected database error occurred in operation '{operation}'"
|
|
100
99
|
) from e
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""SQLAlchemy adapter implementation for py-auth database operations."""
|
|
2
|
+
|
|
3
|
+
from ._utils import handle_db_errors, validate_async_engine, validate_sqlalchemy_model
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Type, List
|
|
6
|
+
from sqlalchemy import delete, select, and_
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
|
8
|
+
from py_auth.exceptions import AdapterError, DuplicateEntryError
|
|
9
|
+
|
|
10
|
+
class SqlAlchemyAdapter:
|
|
11
|
+
"""SQLAlchemy adapter implementation for py-auth database operations with async support."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
engine: AsyncEngine,
|
|
16
|
+
session_model: Type[Any],
|
|
17
|
+
user_model: Type[Any] | None = None,
|
|
18
|
+
account_model: Type[Any] | None = None,
|
|
19
|
+
):
|
|
20
|
+
self.session_model = validate_sqlalchemy_model(
|
|
21
|
+
model=session_model,
|
|
22
|
+
model_name="Session",
|
|
23
|
+
required_columns={
|
|
24
|
+
"id",
|
|
25
|
+
"session_token_hash",
|
|
26
|
+
"user_id",
|
|
27
|
+
"expires",
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if user_model:
|
|
32
|
+
self.user_model = validate_sqlalchemy_model(
|
|
33
|
+
model=user_model,
|
|
34
|
+
model_name="User",
|
|
35
|
+
required_columns={
|
|
36
|
+
"id",
|
|
37
|
+
"email",
|
|
38
|
+
"name",
|
|
39
|
+
"image",
|
|
40
|
+
"password_hash"
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if account_model:
|
|
45
|
+
self.account_model = validate_sqlalchemy_model(
|
|
46
|
+
model=account_model,
|
|
47
|
+
model_name="Account",
|
|
48
|
+
required_columns={
|
|
49
|
+
"id",
|
|
50
|
+
"user_id",
|
|
51
|
+
"type",
|
|
52
|
+
"provider",
|
|
53
|
+
"provider_account_id",
|
|
54
|
+
"access_token",
|
|
55
|
+
"refresh_token",
|
|
56
|
+
"expires_at",
|
|
57
|
+
"token_type",
|
|
58
|
+
"scope",
|
|
59
|
+
"id_token",
|
|
60
|
+
"session_state"
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
self.engine = validate_async_engine(engine=engine)
|
|
66
|
+
self.session_maker = async_sessionmaker(
|
|
67
|
+
bind=self.engine, class_=AsyncSession, expire_on_commit=False
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def _row_to_dict(self, instance: Any) -> Dict[str, Any] | None:
|
|
71
|
+
"""Convert ORM instance to a dictionary using its table column names."""
|
|
72
|
+
if instance is None:
|
|
73
|
+
return None
|
|
74
|
+
cols = [c.name for c in instance.__table__.columns]
|
|
75
|
+
return {name: getattr(instance, name) for name in cols}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def create_user(self, user_data: dict) -> Dict[str, Any] | None:
|
|
81
|
+
if not self.user_model:
|
|
82
|
+
raise AdapterError(
|
|
83
|
+
"Attempted to call 'create_user', "
|
|
84
|
+
"but 'user_model' is not configured."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
async with handle_db_errors(operation="create_user"):
|
|
88
|
+
async with self.session_maker() as session:
|
|
89
|
+
async with session.begin():
|
|
90
|
+
user = self.user_model(**user_data)
|
|
91
|
+
session.add(user)
|
|
92
|
+
return self._row_to_dict(user)
|
|
93
|
+
|
|
94
|
+
async def get_user(self, user_id: str) -> Dict[str, Any] | None:
|
|
95
|
+
if not self.user_model:
|
|
96
|
+
raise AdapterError(
|
|
97
|
+
"Attempted to call 'get_user', "
|
|
98
|
+
"but 'user_model' is not configured."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
async with handle_db_errors(operation="get_user"):
|
|
102
|
+
async with self.session_maker() as session:
|
|
103
|
+
stmt = select(self.user_model).where(self.user_model.id == user_id)
|
|
104
|
+
result = await session.execute(stmt)
|
|
105
|
+
user = result.scalars().first()
|
|
106
|
+
return self._row_to_dict(user)
|
|
107
|
+
|
|
108
|
+
async def get_user_by_email(self, email: str) -> Dict[str, Any] | None:
|
|
109
|
+
if not self.user_model:
|
|
110
|
+
raise AdapterError(
|
|
111
|
+
"Attempted to call 'get_user_by_email', "
|
|
112
|
+
"but 'user_model' is not configured."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
async with handle_db_errors(operation="get_user_by_email"):
|
|
116
|
+
async with self.session_maker() as session:
|
|
117
|
+
stmt = select(self.user_model).where(self.user_model.email == email)
|
|
118
|
+
result = await session.execute(stmt)
|
|
119
|
+
user = result.scalars().first()
|
|
120
|
+
return self._row_to_dict(user)
|
|
121
|
+
|
|
122
|
+
async def update_user(self, user_id: str, updates: Dict[str, Any]) -> Dict[str, Any] | None:
|
|
123
|
+
if not self.user_model:
|
|
124
|
+
raise AdapterError(
|
|
125
|
+
"Attempted to call 'update_user', "
|
|
126
|
+
"but 'user_model' is not configured."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async with handle_db_errors(operation="update_user"):
|
|
130
|
+
async with self.session_maker() as session:
|
|
131
|
+
async with session.begin():
|
|
132
|
+
result = await session.execute(
|
|
133
|
+
select(self.user_model)
|
|
134
|
+
.where(self.user_model.id == user_id)
|
|
135
|
+
.with_for_update()
|
|
136
|
+
)
|
|
137
|
+
user = result.scalars().first()
|
|
138
|
+
|
|
139
|
+
if user is None:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
for key, value in updates.items():
|
|
143
|
+
if hasattr(user, key) and key != "id":
|
|
144
|
+
setattr(user, key, value)
|
|
145
|
+
|
|
146
|
+
await session.refresh(user)
|
|
147
|
+
return self._row_to_dict(user)
|
|
148
|
+
|
|
149
|
+
async def delete_user(self, user_id: str) -> None:
|
|
150
|
+
if not self.user_model:
|
|
151
|
+
raise AdapterError(
|
|
152
|
+
"Attempted to call 'delete_user', "
|
|
153
|
+
"but 'user_model' is not configured."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
async with handle_db_errors(operation="delete_user"):
|
|
157
|
+
async with self.session_maker() as session:
|
|
158
|
+
async with session.begin():
|
|
159
|
+
user = await session.get(self.user_model, user_id)
|
|
160
|
+
if user is None:
|
|
161
|
+
return
|
|
162
|
+
await session.delete(user)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
#
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def list_accounts_for_user(self, user_id: str) -> List[(Dict[str, Any] | None)]:
|
|
171
|
+
if not self.account_model:
|
|
172
|
+
raise AdapterError(
|
|
173
|
+
"Attempted to call 'list_accounts_for_user', "
|
|
174
|
+
"but 'account_model' is not configured."
|
|
175
|
+
)
|
|
176
|
+
async with handle_db_errors(operation="list_accounts_for_user"):
|
|
177
|
+
async with self.session_maker() as session:
|
|
178
|
+
stmt = select(self.account_model).where(self.account_model.user_id == user_id)
|
|
179
|
+
result = await session.execute(stmt)
|
|
180
|
+
return [self._row_to_dict(a) for a in result.scalars().all()]
|
|
181
|
+
|
|
182
|
+
async def link_account(self, account_data: Dict[str, Any]) -> Dict[str, Any] | None:
|
|
183
|
+
if not self.account_model:
|
|
184
|
+
raise AdapterError(
|
|
185
|
+
"Attempted to call 'link_account', "
|
|
186
|
+
"but 'account_model' is not configured."
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
async with handle_db_errors(operation="link_account"):
|
|
190
|
+
async with self.session_maker() as session:
|
|
191
|
+
async with session.begin():
|
|
192
|
+
account = self.account_model(**account_data)
|
|
193
|
+
session.add(account)
|
|
194
|
+
await session.flush()
|
|
195
|
+
return self._row_to_dict(account)
|
|
196
|
+
|
|
197
|
+
async def unlink_account(self, provider: str, provider_account_id: str) -> None:
|
|
198
|
+
if not self.account_model:
|
|
199
|
+
raise AdapterError(
|
|
200
|
+
"Attempted to call 'unlink_account', "
|
|
201
|
+
"but 'account_model' is not configured."
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
async with self.session_maker() as session:
|
|
205
|
+
async with session.begin():
|
|
206
|
+
stmt = (
|
|
207
|
+
delete(self.account_model)
|
|
208
|
+
.where(
|
|
209
|
+
and_(
|
|
210
|
+
self.account_model.provider == provider,
|
|
211
|
+
self.account_model.provider_account_id == provider_account_id,
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
await session.execute(stmt)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def create_session(self, session_data: Dict[str, Any]) -> Dict[str, Any] | None:
|
|
221
|
+
"""Create and persist a new session record."""
|
|
222
|
+
async with handle_db_errors(operation="create_session"):
|
|
223
|
+
async with self.session_maker() as session:
|
|
224
|
+
async with session.begin():
|
|
225
|
+
s = self.session_model(**session_data)
|
|
226
|
+
session.add(s)
|
|
227
|
+
await session.flush()
|
|
228
|
+
return self._row_to_dict(s)
|
|
229
|
+
|
|
230
|
+
async def list_sessions_for_user(self, user_id: str) -> List[(Dict[str, Any] | None)]:
|
|
231
|
+
async with handle_db_errors(operation="list_sessions_for_user"):
|
|
232
|
+
async with self.session_maker() as session:
|
|
233
|
+
stmt = select(self.session_model).where(self.session_model.user_id == user_id)
|
|
234
|
+
result = await session.execute(stmt)
|
|
235
|
+
return [self._row_to_dict(s) for s in result.scalars().all()]
|
|
236
|
+
|
|
237
|
+
async def update_session(
|
|
238
|
+
self, session_id: str, updates: Dict[str, Any]
|
|
239
|
+
) -> Dict[str, Any] | None:
|
|
240
|
+
async with handle_db_errors(operation="update_session"):
|
|
241
|
+
async with self.session_maker() as session:
|
|
242
|
+
async with session.begin():
|
|
243
|
+
result = await session.execute(
|
|
244
|
+
select(self.session_model)
|
|
245
|
+
.where(self.session_model.id == session_id)
|
|
246
|
+
.with_for_update()
|
|
247
|
+
)
|
|
248
|
+
_session = result.scalars().first()
|
|
249
|
+
|
|
250
|
+
if _session is None:
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
for key, value in updates.items():
|
|
254
|
+
if hasattr(_session, key) and key != "id":
|
|
255
|
+
setattr(_session, key, value)
|
|
256
|
+
|
|
257
|
+
await session.refresh(_session)
|
|
258
|
+
return self._row_to_dict(_session)
|
|
259
|
+
|
|
260
|
+
async def get_session_by_session_token_hash(
|
|
261
|
+
self, session_token_hash: str
|
|
262
|
+
) -> Dict[str, Any] | None:
|
|
263
|
+
"""Retrieve a session record by its hashed session token."""
|
|
264
|
+
async with handle_db_errors(operation="get_session_by_session_token_hash"):
|
|
265
|
+
async with self.session_maker() as session:
|
|
266
|
+
stmt = select(self.session_model).where(
|
|
267
|
+
self.session_model.session_token_hash == session_token_hash
|
|
268
|
+
)
|
|
269
|
+
result = await session.execute(stmt)
|
|
270
|
+
s = result.scalar_one_or_none()
|
|
271
|
+
if not s:
|
|
272
|
+
return None
|
|
273
|
+
return self._row_to_dict(s)
|
|
274
|
+
|
|
275
|
+
async def delete_session_by_session_token_hash(
|
|
276
|
+
self, session_token_hash: str
|
|
277
|
+
) -> None:
|
|
278
|
+
"""Delete a session record by its hashed session token."""
|
|
279
|
+
async with handle_db_errors(operation="delete_session_by_session_token_hash"):
|
|
280
|
+
async with self.session_maker() as session:
|
|
281
|
+
async with session.begin():
|
|
282
|
+
stmt = delete(self.session_model).where(
|
|
283
|
+
self.session_model.session_token_hash == session_token_hash
|
|
284
|
+
)
|
|
285
|
+
await session.execute(stmt)
|
|
286
|
+
|
|
287
|
+
async def delete_session(self, session_id: Any) -> None:
|
|
288
|
+
"""Delete a session record by its unique identifier."""
|
|
289
|
+
async with handle_db_errors(operation="delete_session_by_id"):
|
|
290
|
+
async with self.session_maker() as session:
|
|
291
|
+
async with session.begin():
|
|
292
|
+
stmt = delete(self.session_model).where(
|
|
293
|
+
self.session_model.id == session_id
|
|
294
|
+
)
|
|
295
|
+
await session.execute(stmt)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
async def get_or_create_user_and_link_account(self, email: str, user_data: Dict[str, Any], account_data: Dict[str, Any]) -> Dict[str, Any] | None:
|
|
301
|
+
if not self.user_model:
|
|
302
|
+
raise AdapterError(
|
|
303
|
+
"Attempted to call 'get_or_create_user_and_link_account', "
|
|
304
|
+
"but 'user_model' is not configured."
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
if not self.account_model:
|
|
308
|
+
raise AdapterError(
|
|
309
|
+
"Attempted to call 'get_or_create_user_and_link_account', "
|
|
310
|
+
"but 'account_model' is not configured."
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
lookup_email = (user_data.get("email") or email).lower()
|
|
314
|
+
user_data = {**user_data, "email": lookup_email}
|
|
315
|
+
|
|
316
|
+
async with handle_db_errors(operation="get_or_create_user_and_link_account"):
|
|
317
|
+
async with self.session_maker() as session:
|
|
318
|
+
async with session.begin():
|
|
319
|
+
user_result = await session.execute(
|
|
320
|
+
select(self.user_model).where(self.user_model.email == lookup_email)
|
|
321
|
+
)
|
|
322
|
+
user = user_result.scalar_one_or_none()
|
|
323
|
+
|
|
324
|
+
if user is None:
|
|
325
|
+
user = self.user_model(**user_data)
|
|
326
|
+
session.add(user)
|
|
327
|
+
await session.flush()
|
|
328
|
+
|
|
329
|
+
provider = account_data["provider"]
|
|
330
|
+
provider_account_id = account_data["provider_account_id"]
|
|
331
|
+
account_result = await session.execute(select(self.account_model).where(
|
|
332
|
+
self.account_model.provider == provider,
|
|
333
|
+
self.account_model.provider_account_id == provider_account_id,
|
|
334
|
+
))
|
|
335
|
+
account = account_result.scalar_one_or_none()
|
|
336
|
+
|
|
337
|
+
if account is None:
|
|
338
|
+
account = self.account_model(**account_data, user_id=user.id)
|
|
339
|
+
session.add(account)
|
|
340
|
+
await session.flush()
|
|
341
|
+
|
|
342
|
+
elif account.user_id != user.id:
|
|
343
|
+
raise DuplicateEntryError(
|
|
344
|
+
"This social account is already linked to another user profile."
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
user_dict = self._row_to_dict(user)
|
|
348
|
+
account_dict = self._row_to_dict(account)
|
|
349
|
+
return {"user": user_dict, "account": account_dict}
|
|
350
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""SQLAlchemy datetime type for consistent UTC-aware datetime handling across database backends."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import DateTime
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from sqlalchemy.dialects.mysql import DATETIME
|
|
6
|
+
from sqlalchemy.types import TypeDecorator
|
|
7
|
+
|
|
8
|
+
class UTCDateTime(TypeDecorator):
|
|
9
|
+
"""SQLAlchemy datetime type that provides consistent UTC-aware datetimes across supported database backends."""
|
|
10
|
+
|
|
11
|
+
impl = DateTime
|
|
12
|
+
cache_ok = True
|
|
13
|
+
|
|
14
|
+
def load_dialect_impl(self, dialect):
|
|
15
|
+
if dialect.name == "mysql":
|
|
16
|
+
return dialect.type_descriptor(
|
|
17
|
+
DATETIME(fsp=6)
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if dialect.name == "sqlite":
|
|
21
|
+
return dialect.type_descriptor(
|
|
22
|
+
DateTime()
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
return dialect.type_descriptor(
|
|
26
|
+
DateTime(timezone=True)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def process_bind_param(
|
|
30
|
+
self,
|
|
31
|
+
value: datetime | None,
|
|
32
|
+
dialect,
|
|
33
|
+
):
|
|
34
|
+
if value is None:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
if value.tzinfo is None:
|
|
38
|
+
raise ValueError("UTCDateTime requires a timezone-aware datetime")
|
|
39
|
+
|
|
40
|
+
value = value.astimezone(timezone.utc)
|
|
41
|
+
|
|
42
|
+
if dialect.name in ("mysql", "sqlite"):
|
|
43
|
+
return value.replace(tzinfo=None)
|
|
44
|
+
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
def process_result_value(
|
|
48
|
+
self,
|
|
49
|
+
value: datetime | None,
|
|
50
|
+
dialect,
|
|
51
|
+
):
|
|
52
|
+
if value is None:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
if value.tzinfo is None:
|
|
56
|
+
return value.replace(tzinfo=timezone.utc)
|
|
57
|
+
|
|
58
|
+
return value.astimezone(timezone.utc)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py-auth-sqlalchemy
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: High-performance, async SQLAlchemy adapter for py-auth-core.
|
|
5
|
+
Author-email: Olatunji Jamaldeen Omotoyosi <jamaldeen.o@yahoo.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jamaldeen09/py-auth
|
|
8
|
+
Project-URL: Repository, https://github.com/jamaldeen09/py-auth
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: sqlalchemy>=2.0.0
|
|
20
|
+
Requires-Dist: greenlet>=3.0.0
|
|
21
|
+
Requires-Dist: py-auth-core>=0.0.3
|
|
22
|
+
Dynamic: license-file
|
{py_auth_sqlalchemy-0.0.1 → py_auth_sqlalchemy-0.0.2}/src/py_auth_sqlalchemy.egg-info/SOURCES.txt
RENAMED
|
@@ -2,9 +2,10 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
src/py_auth_sqlalchemy/__init__.py
|
|
5
|
+
src/py_auth_sqlalchemy/_utils.py
|
|
5
6
|
src/py_auth_sqlalchemy/core.py
|
|
6
7
|
src/py_auth_sqlalchemy/py.typed
|
|
7
|
-
src/py_auth_sqlalchemy/
|
|
8
|
+
src/py_auth_sqlalchemy/utc_datetime.py
|
|
8
9
|
src/py_auth_sqlalchemy.egg-info/PKG-INFO
|
|
9
10
|
src/py_auth_sqlalchemy.egg-info/SOURCES.txt
|
|
10
11
|
src/py_auth_sqlalchemy.egg-info/dependency_links.txt
|