apppy-sb 0.1.0__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.
@@ -0,0 +1,28 @@
1
+ __generated__/
2
+ dist/
3
+ *.egg-info
4
+ .env
5
+ .env.*
6
+ *.env
7
+ !.env.ci
8
+ .file_store/
9
+ *.pid
10
+ .python-version
11
+ *.secrets
12
+ .secrets
13
+ *.tar.gz
14
+ *.test_output/
15
+ .test_output/
16
+ uv.lock
17
+ *.whl
18
+
19
+ # System files
20
+ __pycache__
21
+ .DS_Store
22
+
23
+ # Editor files
24
+ *.sublime-project
25
+ *.sublime-workspace
26
+ .vscode/*
27
+ !.vscode/settings.json
28
+
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: apppy-sb
3
+ Version: 0.1.0
4
+ Summary: Supabase integrations for server development
5
+ Project-URL: Homepage, https://github.com/spals/apppy
6
+ Author: Tim Kral
7
+ License: MIT
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: apppy-auth>=0.1.0
12
+ Requires-Dist: apppy-db>=0.1.0
13
+ Requires-Dist: apppy-env>=0.1.0
14
+ Requires-Dist: apppy-fs>=0.1.0
15
+ Requires-Dist: apppy-generic>=0.1.0
16
+ Requires-Dist: apppy-logger>=0.1.0
17
+ Requires-Dist: apppy-queues>=0.1.0
18
+ Requires-Dist: argon2-cffi==25.1.0
19
+ Requires-Dist: fastapi-lifespan-manager==0.1.4
20
+ Requires-Dist: requests==2.32.3
21
+ Requires-Dist: supabase==2.15.3
22
+ Requires-Dist: tuspy==1.1.0
File without changes
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "apppy-sb"
7
+ version = "0.1.0"
8
+ description = "Supabase integrations for server development"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = {text = "MIT"}
12
+ authors = [{ name = "Tim Kral" }]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "License :: OSI Approved :: MIT License",
16
+ ]
17
+ dependencies = [
18
+ "apppy-auth>=0.1.0",
19
+ "apppy-env>=0.1.0",
20
+ "apppy-db>=0.1.0",
21
+ "apppy-fs>=0.1.0",
22
+ "apppy-generic>=0.1.0",
23
+ "apppy-logger>=0.1.0",
24
+ "apppy-queues>=0.1.0",
25
+ "argon2-cffi==25.1.0",
26
+ "fastapi-lifespan-manager==0.1.4",
27
+ "requests==2.32.3",
28
+ "supabase==2.15.3",
29
+ "tuspy==1.1.0"
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/spals/apppy"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/apppy"]
apppy_sb-0.1.0/sb.mk ADDED
@@ -0,0 +1,23 @@
1
+ ifndef APPPY_SB_MK_INCLUDED
2
+ APPPY_SB_MK_INCLUDED := 1
3
+ SB_PKG_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
4
+
5
+ .PHONY: sb sb-dev sb/build sb/clean sb/install sb/install-dev
6
+
7
+ sb: sb/clean sb/install
8
+
9
+ sb-dev: sb/clean sb/install-dev
10
+
11
+ sb/build:
12
+ cd $(SB_PKG_DIR) && uvx --from build pyproject-build
13
+
14
+ sb/clean:
15
+ cd $(SB_PKG_DIR) && rm -rf dist/ *.egg-info .venv
16
+
17
+ sb/install: sb/build
18
+ cd $(SB_PKG_DIR) && uv pip install dist/*.whl
19
+
20
+ sb/install-dev:
21
+ cd $(SB_PKG_DIR) && uv pip install -e .
22
+
23
+ endif # APPPY_SB_MK_INCLUDED
File without changes
@@ -0,0 +1,334 @@
1
+ import asyncio
2
+ import datetime
3
+ import uuid
4
+ from typing import Any, get_args
5
+
6
+ import gotrue
7
+ import gotrue.errors
8
+ import gotrue.helpers
9
+ import psycopg
10
+ from argon2 import PasswordHasher
11
+ from fastapi_lifespan_manager import LifespanManager
12
+ from pydantic import Field
13
+ from supabase.client import AsyncClient as NativeSupabaseAsyncClient
14
+ from supabase.client import AsyncSupabaseAuthClient as NativeSupabaseAuthAsyncClient
15
+
16
+ # AsyncAuthClient as NativeSupabaseAuthAsyncClient,
17
+ from supabase.client import (
18
+ create_async_client,
19
+ )
20
+ from supabase.lib.client_options import AsyncClientOptions as NativeSupabaseAsyncClientOptions
21
+
22
+ from apppy.auth.errors.user import (
23
+ UserSessionRefreshMissingSessionError,
24
+ UserSignInInvalidCredentialsError,
25
+ UserSignInServerError,
26
+ UserSignOutServerError,
27
+ UserSignUpInvalidCredentialsError,
28
+ UserSignUpServerError,
29
+ UserSignUpTooManyRetriesError,
30
+ )
31
+ from apppy.db.postgres import PostgresClient
32
+ from apppy.env import EnvSettings
33
+ from apppy.logger import WithLogger
34
+
35
+
36
+ class SupabaseAuthSessionStorage(gotrue.AsyncSupportedStorage, WithLogger):
37
+ """
38
+ A storage implementation for Supabase Auth that uses the FastAPI session storage.
39
+
40
+ This allows us to persist state across requests, which is required for OAuth flows.
41
+ """
42
+
43
+ def __init__(self, session):
44
+ self._session = session
45
+
46
+ async def get_item(self, key: str) -> str | None:
47
+ return self._session.get(key)
48
+
49
+ async def set_item(self, key: str, value: str) -> None:
50
+ self._session[key] = value
51
+
52
+ async def remove_item(self, key: str) -> None:
53
+ self._session.pop(key, None)
54
+
55
+
56
+ class SupabaseAuthSettings(EnvSettings):
57
+ api_anon_key: str = Field(alias="APP_SUPABASE_AUTH_API_ANON_KEY")
58
+ api_key: str = Field(alias="APP_SUPABASE_AUTH_API_KEY", exclude=True)
59
+ api_url: str = Field(alias="APP_SUPABASE_AUTH_API_URL")
60
+
61
+ max_user_signup_retries: int = Field(
62
+ alias="APP_SUPABASE_AUTH_MAX_USER_SIGNUP_RETRIES", default=2
63
+ )
64
+
65
+
66
+ class SupabaseAuthAdmin(WithLogger):
67
+ def __init__(
68
+ self,
69
+ settings: SupabaseAuthSettings,
70
+ lifespan: LifespanManager,
71
+ postgres_client: PostgresClient,
72
+ ):
73
+ self._settings = settings
74
+ self._postgres_client = postgres_client
75
+
76
+ lifespan.add(self._auth_admin_async_client)
77
+
78
+ async def _auth_admin_async_client(self):
79
+ native_anon_async_client = await create_async_client(
80
+ supabase_url=self._settings.api_url,
81
+ supabase_key=self._settings.api_key,
82
+ )
83
+
84
+ self._supabase_auth_admin = native_anon_async_client.auth.admin
85
+ yield {"supbase_auth_admin": self._supabase_auth_admin}
86
+
87
+ self._logger.info("Closing Supabase Auth Admin client")
88
+ await self._supabase_auth_admin.close()
89
+
90
+ def _normalize_result_row(self, row: dict) -> dict:
91
+ def _normalize_key(key: str):
92
+ if key == "raw_app_meta_data":
93
+ return "app_metadata"
94
+ elif key == "raw_user_meta_data":
95
+ return "user_metadata"
96
+
97
+ return key
98
+
99
+ return {
100
+ _normalize_key(k): str(v) if isinstance(v, uuid.UUID | datetime.datetime) else v
101
+ for k, v in row.items()
102
+ }
103
+
104
+ async def create_user(self, attributes: gotrue.AdminUserAttributes) -> gotrue.User:
105
+ auth_user_query = """
106
+ SELECT *
107
+ FROM auth.users
108
+ WHERE email = %(email)s
109
+ LIMIT 1
110
+ """
111
+ try:
112
+ result_set = await self._postgres_client.db_query_async(
113
+ auth_user_query, {"email": attributes["email"]}
114
+ )
115
+
116
+ if result_set is None or len(result_set) != 1:
117
+ user_resp = await self._supabase_auth_admin.create_user(attributes)
118
+ return user_resp.user
119
+ else:
120
+ user_dict = self._normalize_result_row(result_set[0])
121
+ user_resp = gotrue.helpers.parse_user_response(user_dict)
122
+ return user_resp.user
123
+ except psycopg.DatabaseError as e:
124
+ self._logger.exception("Lookup during create user encountered an api error")
125
+ raise UserSignUpServerError("auth_user_lookup_error") from e
126
+ except gotrue.errors.AuthApiError as e:
127
+ self._logger.exception(
128
+ "Create user encountered an auth api error",
129
+ extra={"error_name": e.name},
130
+ )
131
+ raise UserSignUpServerError(e.code) from e
132
+
133
+
134
+ class SupabaseAuth(WithLogger):
135
+ def __init__(
136
+ self,
137
+ settings: SupabaseAuthSettings,
138
+ supabase_auth_admin: SupabaseAuthAdmin,
139
+ ) -> None:
140
+ self._settings = settings
141
+ self._password_hasher = PasswordHasher()
142
+ self._supabase_auth_admin = supabase_auth_admin
143
+
144
+ async def auth_async_client(
145
+ self, session: dict[str, Any] | None = None
146
+ ) -> NativeSupabaseAuthAsyncClient:
147
+ if session is not None:
148
+ client_options = NativeSupabaseAsyncClientOptions(
149
+ storage=SupabaseAuthSessionStorage(session)
150
+ )
151
+ else:
152
+ client_options = NativeSupabaseAsyncClientOptions()
153
+
154
+ native_anon_async_client: NativeSupabaseAsyncClient = await create_async_client(
155
+ supabase_url=self._settings.api_url,
156
+ supabase_key=self._settings.api_anon_key,
157
+ options=client_options,
158
+ )
159
+
160
+ return native_anon_async_client.auth
161
+
162
+ def is_native_supabase_provider(self, provider: str) -> bool:
163
+ return provider in get_args(gotrue.Provider)
164
+
165
+ async def user_session_login_id_token(
166
+ self,
167
+ credentials: gotrue.SignInWithIdTokenCredentials,
168
+ ) -> gotrue.AuthResponse:
169
+ try:
170
+ auth_async_client: NativeSupabaseAuthAsyncClient = await self.auth_async_client()
171
+ auth_resp: gotrue.AuthResponse = await auth_async_client.sign_in_with_id_token(
172
+ credentials
173
+ )
174
+
175
+ return auth_resp
176
+ except gotrue.errors.AuthApiError as e:
177
+ self._logger.exception(
178
+ "Login with id token encountered an auth api error",
179
+ extra={"error_name": e.name, "error_code": e.code, "error_message": e.message},
180
+ )
181
+ raise UserSignInServerError(e.code) from e
182
+ except gotrue.errors.CustomAuthError as e:
183
+ self._logger.exception(
184
+ "Login with id token encountered an auth custom error",
185
+ extra={"error_name": e.name, "error_code": e.code, "error_message": e.message},
186
+ )
187
+ raise UserSignInServerError(e.code) from e
188
+
189
+ async def user_session_login_oauth_finalize(
190
+ self,
191
+ session: dict[str, Any],
192
+ params: gotrue.CodeExchangeParams,
193
+ ) -> gotrue.AuthResponse:
194
+ try:
195
+ auth_async_client: NativeSupabaseAuthAsyncClient = await self.auth_async_client(session)
196
+ auth_resp: gotrue.AuthResponse = await auth_async_client.exchange_code_for_session(
197
+ params
198
+ )
199
+
200
+ return auth_resp
201
+ except gotrue.errors.AuthApiError as e:
202
+ self._logger.exception(
203
+ "Oauth login finalize encountered an auth api error",
204
+ extra={"error_name": e.name, "error_code": e.code, "error_message": e.message},
205
+ )
206
+ raise UserSignInServerError(e.code) from e
207
+ except gotrue.errors.CustomAuthError as e:
208
+ self._logger.exception(
209
+ "OAuth login finalize encountered an auth custom error",
210
+ extra={"error_name": e.name, "error_code": e.code, "error_message": e.message},
211
+ )
212
+ raise UserSignInServerError(e.code) from e
213
+
214
+ async def user_session_login_oauth(
215
+ self,
216
+ session: dict[str, Any],
217
+ credentials: gotrue.SignInWithOAuthCredentials,
218
+ ) -> gotrue.OAuthResponse:
219
+ try:
220
+ auth_async_client: NativeSupabaseAuthAsyncClient = await self.auth_async_client(session)
221
+ oauth_resp: gotrue.OAuthResponse = await auth_async_client.sign_in_with_oauth(
222
+ credentials
223
+ )
224
+
225
+ return oauth_resp
226
+ except gotrue.errors.AuthApiError as e:
227
+ self._logger.exception(
228
+ "Login with oauth encountered an auth api error",
229
+ extra={"error_name": e.name, "error_code": e.code, "error_message": e.message},
230
+ )
231
+ raise UserSignInServerError(e.code) from e
232
+ except gotrue.errors.CustomAuthError as e:
233
+ self._logger.exception(
234
+ "Login with oauth encountered an auth custom error",
235
+ extra={"error_name": e.name, "error_code": e.code, "error_message": e.message},
236
+ )
237
+ raise UserSignInServerError(e.code) from e
238
+
239
+ async def user_session_login_password(
240
+ self, credentials: gotrue.SignInWithPasswordCredentials
241
+ ) -> gotrue.AuthResponse:
242
+ try:
243
+ auth_async_client: NativeSupabaseAuthAsyncClient = await self.auth_async_client()
244
+ auth_resp: gotrue.AuthResponse = await auth_async_client.sign_in_with_password(
245
+ credentials
246
+ )
247
+
248
+ return auth_resp
249
+ except gotrue.errors.AuthApiError as e:
250
+ if e.message == "Invalid login credentials":
251
+ raise UserSignInInvalidCredentialsError() from e
252
+
253
+ self._logger.exception(
254
+ "Login with password encountered an auth api error",
255
+ extra={"error_name": e.name, "error_code": e.code, "error_message": e.message},
256
+ )
257
+ raise UserSignInServerError(e.code) from e
258
+ except gotrue.errors.CustomAuthError as e:
259
+ self._logger.exception(
260
+ "Login with password encountered an auth custom error",
261
+ extra={"error_name": e.name, "error_code": e.code, "error_message": e.message},
262
+ )
263
+ raise UserSignInServerError(e.code) from e
264
+
265
+ async def user_session_logout(self, options: gotrue.SignOutOptions) -> None:
266
+ auth_async_client: NativeSupabaseAuthAsyncClient = await self.auth_async_client()
267
+ try:
268
+ await auth_async_client.sign_out(options)
269
+ except gotrue.errors.AuthError as e:
270
+ self._logger.exception(
271
+ "Logout encountered an auth error",
272
+ extra={"error_name": e.name, "error_code": e.code, "error_message": e.message},
273
+ )
274
+ raise UserSignOutServerError(e.code) from e
275
+
276
+ async def user_session_refresh(self, refresh_token: str) -> gotrue.AuthResponse:
277
+ try:
278
+ auth_async_client: NativeSupabaseAuthAsyncClient = await self.auth_async_client()
279
+ auth_resp: gotrue.AuthResponse = await auth_async_client.refresh_session(refresh_token)
280
+ return auth_resp
281
+ except gotrue.errors.AuthSessionMissingError as e:
282
+ raise UserSessionRefreshMissingSessionError() from e
283
+
284
+ async def user_sign_up(
285
+ self, credentials: gotrue.SignUpWithPasswordCredentials
286
+ ) -> gotrue.AuthResponse:
287
+ attempt = 0
288
+ while attempt < self._settings.max_user_signup_retries:
289
+ try:
290
+ auth_async_client = await self.auth_async_client()
291
+ auth_resp: gotrue.AuthResponse = await auth_async_client.sign_up(credentials)
292
+ return auth_resp
293
+ except gotrue.errors.AuthRetryableError:
294
+ self._logger.warning(
295
+ "Encountered an AuthRetryableError with signing up a Supabase user. Retrying.",
296
+ extra={
297
+ "attempt": attempt,
298
+ "email": credentials.get("email"),
299
+ "phone": credentials.get("phone"),
300
+ },
301
+ )
302
+ attempt += 1
303
+ await asyncio.sleep(0.5)
304
+ except gotrue.errors.AuthInvalidCredentialsError as e:
305
+ self._logger.exception(
306
+ e.message,
307
+ extra={"email": credentials.get("email"), "phone": credentials.get("phone")},
308
+ )
309
+ raise UserSignUpInvalidCredentialsError() from e
310
+ except gotrue.errors.AuthApiError as e:
311
+ self._logger.exception(
312
+ "Sign up encountered an auth api error",
313
+ extra={"email": credentials.get("email"), "phone": credentials.get("phone")},
314
+ )
315
+ raise UserSignUpServerError(e.code) from e
316
+
317
+ self._logger.error(
318
+ "Too many retry attempts to sign up a Supabase user",
319
+ extra={
320
+ "attempts": attempt,
321
+ "email": credentials.get("email"),
322
+ "phone": credentials.get("phone"),
323
+ },
324
+ )
325
+ raise UserSignUpTooManyRetriesError()
326
+
327
+ async def user_sign_up_id_token(
328
+ self,
329
+ provider: str,
330
+ attributes: gotrue.AdminUserAttributes,
331
+ ) -> None:
332
+ user: gotrue.User = await self._supabase_auth_admin.create_user(attributes)
333
+ if user is None:
334
+ raise UserSignUpServerError("user_creation_failed_in_sign_up_id_token")
@@ -0,0 +1,46 @@
1
+ from fastapi_lifespan_manager import LifespanManager
2
+ from pydantic import Field
3
+ from supabase import AsyncClient as NativeSupabaseAsyncClient
4
+ from supabase import Client as NativeSupabaseClient
5
+ from supabase.client import create_async_client, create_client
6
+
7
+ from apppy.env import EnvSettings
8
+ from apppy.logger import WithLogger
9
+
10
+ _SUPABASE_MICRO_MAX_CONNS = 200
11
+ _SUPABASE_SMALL_MAX_CONNS = 400
12
+
13
+
14
+ class SupabaseClientSettings(EnvSettings):
15
+ api_anon_key: str = Field(alias="APP_SUPABASE_API_ANON_KEY")
16
+ api_key: str = Field(alias="APP_SUPABASE_API_KEY", exclude=True)
17
+ api_url: str = Field(alias="APP_SUPABASE_API_URL")
18
+
19
+
20
+ class SupabaseClient(WithLogger):
21
+ def __init__(self, settings: SupabaseClientSettings, lifespan: LifespanManager) -> None:
22
+ self._settings = settings
23
+ self._native_internal_client: NativeSupabaseClient = create_client(
24
+ supabase_url=settings.api_url, supabase_key=settings.api_key
25
+ )
26
+
27
+ lifespan.add(self.__create_async_client)
28
+
29
+ async def __create_async_client(self):
30
+ self._logger.info("Creating native_internal_async_client")
31
+ self._native_internal_async_client = await create_async_client(
32
+ supabase_url=self._settings.api_url,
33
+ supabase_key=self._settings.api_key,
34
+ )
35
+
36
+ yield {"native_internal_async_client": self._native_internal_async_client}
37
+
38
+ self._logger.info("Closing native_internal_async_client")
39
+
40
+ @property
41
+ def internal_client(self) -> NativeSupabaseClient:
42
+ return self._native_internal_client
43
+
44
+ @property
45
+ def internal_async_client(self) -> NativeSupabaseAsyncClient:
46
+ return self._native_internal_async_client