supython 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.
Files changed (200) hide show
  1. supython/__init__.py +24 -0
  2. supython/admin/__init__.py +3 -0
  3. supython/admin/api/__init__.py +24 -0
  4. supython/admin/api/auth.py +118 -0
  5. supython/admin/api/auth_templates.py +67 -0
  6. supython/admin/api/auth_users.py +225 -0
  7. supython/admin/api/db.py +174 -0
  8. supython/admin/api/functions.py +92 -0
  9. supython/admin/api/jobs.py +192 -0
  10. supython/admin/api/ops.py +224 -0
  11. supython/admin/api/realtime.py +281 -0
  12. supython/admin/api/service_auth.py +49 -0
  13. supython/admin/api/service_auth_templates.py +83 -0
  14. supython/admin/api/service_auth_users.py +346 -0
  15. supython/admin/api/service_db.py +214 -0
  16. supython/admin/api/service_functions.py +287 -0
  17. supython/admin/api/service_jobs.py +282 -0
  18. supython/admin/api/service_ops.py +213 -0
  19. supython/admin/api/service_realtime.py +30 -0
  20. supython/admin/api/service_storage.py +220 -0
  21. supython/admin/api/storage.py +117 -0
  22. supython/admin/api/system.py +37 -0
  23. supython/admin/audit.py +29 -0
  24. supython/admin/deps.py +22 -0
  25. supython/admin/errors.py +16 -0
  26. supython/admin/schemas.py +310 -0
  27. supython/admin/session.py +52 -0
  28. supython/admin/spa.py +38 -0
  29. supython/admin/static/assets/Alert-dluGVkos.js +49 -0
  30. supython/admin/static/assets/Audit-Njung3HI.js +2 -0
  31. supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
  32. supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
  33. supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
  34. supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
  35. supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
  36. supython/admin/static/assets/Crons-B67vc39F.js +2 -0
  37. supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
  38. supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
  39. supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
  40. supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
  41. supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
  42. supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
  43. supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
  44. supython/admin/static/assets/Input-DppYTq9C.js +259 -0
  45. supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
  46. supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
  47. supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
  48. supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
  49. supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
  50. supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
  51. supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
  52. supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
  53. supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
  54. supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
  55. supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
  56. supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
  57. supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
  58. supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
  59. supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
  60. supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
  61. supython/admin/static/assets/Space-n5-XcguU.js +400 -0
  62. supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
  63. supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
  64. supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
  65. supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
  66. supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
  67. supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
  68. supython/admin/static/assets/Users-wzwajhlh.js +2 -0
  69. supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
  70. supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
  71. supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
  72. supython/admin/static/assets/get-Ca6unauB.js +2 -0
  73. supython/admin/static/assets/index-CeE6v959.js +951 -0
  74. supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
  75. supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
  76. supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
  77. supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
  78. supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
  79. supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
  80. supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
  81. supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
  82. supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
  83. supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
  84. supython/admin/static/favicon.svg +1 -0
  85. supython/admin/static/icons.svg +24 -0
  86. supython/admin/static/index.html +24 -0
  87. supython/app.py +162 -0
  88. supython/auth/__init__.py +3 -0
  89. supython/auth/_email_job.py +11 -0
  90. supython/auth/providers/__init__.py +34 -0
  91. supython/auth/providers/github.py +22 -0
  92. supython/auth/providers/google.py +19 -0
  93. supython/auth/providers/oauth.py +56 -0
  94. supython/auth/providers/registry.py +16 -0
  95. supython/auth/ratelimit.py +39 -0
  96. supython/auth/router.py +282 -0
  97. supython/auth/schemas.py +79 -0
  98. supython/auth/service.py +587 -0
  99. supython/backups/__init__.py +24 -0
  100. supython/backups/_backup_job.py +170 -0
  101. supython/backups/schemas.py +18 -0
  102. supython/backups/service.py +217 -0
  103. supython/body_size.py +184 -0
  104. supython/cli.py +1663 -0
  105. supython/client/__init__.py +67 -0
  106. supython/client/_auth.py +249 -0
  107. supython/client/_client.py +145 -0
  108. supython/client/_config.py +92 -0
  109. supython/client/_functions.py +69 -0
  110. supython/client/_storage.py +255 -0
  111. supython/client/py.typed +0 -0
  112. supython/db.py +151 -0
  113. supython/db_admin.py +8 -0
  114. supython/extensions.py +36 -0
  115. supython/functions/__init__.py +19 -0
  116. supython/functions/context.py +262 -0
  117. supython/functions/loader.py +307 -0
  118. supython/functions/router.py +228 -0
  119. supython/functions/schemas.py +50 -0
  120. supython/gen/__init__.py +5 -0
  121. supython/gen/_introspect.py +137 -0
  122. supython/gen/types_py.py +270 -0
  123. supython/gen/types_ts.py +365 -0
  124. supython/health.py +229 -0
  125. supython/hooks.py +117 -0
  126. supython/jobs/__init__.py +31 -0
  127. supython/jobs/backends.py +97 -0
  128. supython/jobs/context.py +58 -0
  129. supython/jobs/cron.py +152 -0
  130. supython/jobs/cron_inproc.py +119 -0
  131. supython/jobs/decorators.py +76 -0
  132. supython/jobs/registry.py +79 -0
  133. supython/jobs/router.py +136 -0
  134. supython/jobs/schemas.py +92 -0
  135. supython/jobs/service.py +311 -0
  136. supython/jobs/worker.py +219 -0
  137. supython/jwks.py +257 -0
  138. supython/keyset.py +279 -0
  139. supython/logging_config.py +291 -0
  140. supython/mail.py +33 -0
  141. supython/mailer.py +65 -0
  142. supython/migrate.py +81 -0
  143. supython/migrations/0001_extensions_and_roles.sql +46 -0
  144. supython/migrations/0002_auth_schema.sql +66 -0
  145. supython/migrations/0003_demo_todos.sql +42 -0
  146. supython/migrations/0004_auth_v0_2.sql +47 -0
  147. supython/migrations/0005_storage_schema.sql +117 -0
  148. supython/migrations/0006_realtime_schema.sql +206 -0
  149. supython/migrations/0007_jobs_schema.sql +254 -0
  150. supython/migrations/0008_jobs_last_error.sql +56 -0
  151. supython/migrations/0009_auth_rate_limits.sql +33 -0
  152. supython/migrations/0010_worker_heartbeat.sql +14 -0
  153. supython/migrations/0011_admin_schema.sql +45 -0
  154. supython/migrations/0012_auth_banned_until.sql +10 -0
  155. supython/migrations/0013_email_templates.sql +19 -0
  156. supython/migrations/0014_realtime_payload_warning.sql +96 -0
  157. supython/migrations/0015_backups_schema.sql +14 -0
  158. supython/passwords.py +15 -0
  159. supython/realtime/__init__.py +6 -0
  160. supython/realtime/broker.py +814 -0
  161. supython/realtime/protocol.py +234 -0
  162. supython/realtime/router.py +184 -0
  163. supython/realtime/schemas.py +207 -0
  164. supython/realtime/service.py +261 -0
  165. supython/realtime/topics.py +175 -0
  166. supython/realtime/websocket.py +586 -0
  167. supython/scaffold/__init__.py +5 -0
  168. supython/scaffold/init_project.py +144 -0
  169. supython/scaffold/templates/Caddyfile.tmpl +4 -0
  170. supython/scaffold/templates/README.md.tmpl +22 -0
  171. supython/scaffold/templates/apps_hooks.py.tmpl +11 -0
  172. supython/scaffold/templates/apps_jobs.py.tmpl +8 -0
  173. supython/scaffold/templates/asgi.py.tmpl +14 -0
  174. supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
  175. supython/scaffold/templates/docker-compose.yml.tmpl +45 -0
  176. supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
  177. supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
  178. supython/scaffold/templates/env.example.tmpl +168 -0
  179. supython/scaffold/templates/functions_README.md.tmpl +21 -0
  180. supython/scaffold/templates/gitignore.tmpl +14 -0
  181. supython/scaffold/templates/manage.py.tmpl +11 -0
  182. supython/scaffold/templates/migrations/.gitkeep +0 -0
  183. supython/scaffold/templates/package_init.py.tmpl +1 -0
  184. supython/scaffold/templates/settings.py.tmpl +31 -0
  185. supython/secretset.py +347 -0
  186. supython/security_headers.py +78 -0
  187. supython/settings.py +244 -0
  188. supython/settings_module.py +117 -0
  189. supython/storage/__init__.py +5 -0
  190. supython/storage/backends.py +392 -0
  191. supython/storage/router.py +341 -0
  192. supython/storage/schemas.py +50 -0
  193. supython/storage/service.py +445 -0
  194. supython/storage/signing.py +119 -0
  195. supython/tokens.py +85 -0
  196. supython-0.1.0.dist-info/METADATA +756 -0
  197. supython-0.1.0.dist-info/RECORD +200 -0
  198. supython-0.1.0.dist-info/WHEEL +4 -0
  199. supython-0.1.0.dist-info/entry_points.txt +2 -0
  200. supython-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,67 @@
1
+ from ._auth import (
2
+ SIGNED_IN,
3
+ SIGNED_OUT,
4
+ TOKEN_REFRESHED,
5
+ USER_UPDATED,
6
+ AuthChangeCallback,
7
+ AuthError,
8
+ Session,
9
+ SupythonResponse,
10
+ TokenResponse,
11
+ UserResponse,
12
+ )
13
+ from ._client import Client
14
+ from ._config import (
15
+ AuthOptions,
16
+ AuthStorageBackend,
17
+ ClientOptions,
18
+ FileAuthStorage,
19
+ MemoryAuthStorage,
20
+ )
21
+ from ._functions import FunctionsClient, FunctionsError
22
+ from ._storage import (
23
+ BucketResponse,
24
+ ObjectResponse,
25
+ SignedUrlResponse,
26
+ StorageBucket,
27
+ StorageClient,
28
+ StorageError,
29
+ )
30
+
31
+
32
+ def create_client(
33
+ supython_url: str,
34
+ anon_key: str = "",
35
+ *,
36
+ options: ClientOptions | None = None,
37
+ ) -> Client:
38
+ return Client(supython_url, anon_key, options=options)
39
+
40
+
41
+ __all__ = [
42
+ "create_client",
43
+ "Client",
44
+ "ClientOptions",
45
+ "AuthOptions",
46
+ "AuthError",
47
+ "AuthChangeCallback",
48
+ "Session",
49
+ "SupythonResponse",
50
+ "TokenResponse",
51
+ "UserResponse",
52
+ "SIGNED_IN",
53
+ "SIGNED_OUT",
54
+ "TOKEN_REFRESHED",
55
+ "USER_UPDATED",
56
+ "StorageClient",
57
+ "StorageBucket",
58
+ "StorageError",
59
+ "BucketResponse",
60
+ "ObjectResponse",
61
+ "SignedUrlResponse",
62
+ "FunctionsClient",
63
+ "FunctionsError",
64
+ "AuthStorageBackend",
65
+ "MemoryAuthStorage",
66
+ "FileAuthStorage",
67
+ ]
@@ -0,0 +1,249 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from typing import Any, Generic, TypeVar
7
+
8
+ import httpx
9
+
10
+ from ._config import _parse_error_detail
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ @dataclass
16
+ class AuthError:
17
+ code: str
18
+ message: str
19
+ status: int
20
+
21
+
22
+ @dataclass
23
+ class UserResponse:
24
+ id: str
25
+ email: str
26
+ created_at: str
27
+
28
+
29
+ @dataclass
30
+ class TokenResponse:
31
+ access_token: str
32
+ token_type: str
33
+ expires_in: int
34
+ refresh_token: str
35
+ user: UserResponse
36
+
37
+
38
+ @dataclass
39
+ class Session:
40
+ access_token: str
41
+ refresh_token: str
42
+ expires_in: int
43
+ user: UserResponse | None
44
+
45
+
46
+ @dataclass
47
+ class SupythonResponse(Generic[T]):
48
+ data: T | None = None
49
+ error: AuthError | None = None
50
+
51
+
52
+ SIGNED_IN = "SIGNED_IN"
53
+ SIGNED_OUT = "SIGNED_OUT"
54
+ TOKEN_REFRESHED = "TOKEN_REFRESHED"
55
+ USER_UPDATED = "USER_UPDATED"
56
+
57
+
58
+ AuthChangeCallback = Callable[[str, Session | None], None]
59
+
60
+
61
+ def _parse_token_response(body: dict[str, Any]) -> TokenResponse:
62
+ user_body = body["user"]
63
+ user = UserResponse(
64
+ id=str(user_body["id"]),
65
+ email=user_body["email"],
66
+ created_at=user_body["created_at"],
67
+ )
68
+ return TokenResponse(
69
+ access_token=body["access_token"],
70
+ token_type=body.get("token_type", "bearer"),
71
+ expires_in=body["expires_in"],
72
+ refresh_token=body["refresh_token"],
73
+ user=user,
74
+ )
75
+
76
+
77
+ def _parse_user_response(body: dict[str, Any]) -> UserResponse:
78
+ return UserResponse(
79
+ id=str(body["id"]),
80
+ email=body["email"],
81
+ created_at=body["created_at"],
82
+ )
83
+
84
+
85
+ def _make_auth_error(resp: httpx.Response) -> AuthError:
86
+ try:
87
+ body = resp.json()
88
+ except Exception:
89
+ return AuthError("network_error", resp.text or f"HTTP {resp.status_code}", resp.status_code)
90
+ code, message = _parse_error_detail(body)
91
+ return AuthError(code, message, resp.status_code)
92
+
93
+
94
+ class AuthClient:
95
+ def __init__(self, client: Any, base_url: str) -> None:
96
+ self._client = client
97
+ self._url = base_url
98
+ self._http = httpx.AsyncClient()
99
+ self._callbacks: list[AuthChangeCallback] = []
100
+
101
+ def on_auth_state_change(self, callback: AuthChangeCallback) -> Callable[[], None]:
102
+ self._callbacks.append(callback)
103
+
104
+ def unsubscribe() -> None:
105
+ with contextlib.suppress(ValueError):
106
+ self._callbacks.remove(callback)
107
+
108
+ return unsubscribe
109
+
110
+ def _emit(self, event: str, session: Session | None) -> None:
111
+ for cb in self._callbacks:
112
+ with contextlib.suppress(Exception):
113
+ cb(event, session)
114
+
115
+ async def sign_up(self, email: str, password: str) -> SupythonResponse[TokenResponse]:
116
+ try:
117
+ resp = await self._http.post(
118
+ f"{self._url}/signup",
119
+ json={"email": email, "password": password},
120
+ )
121
+ except httpx.HTTPError as exc:
122
+ return SupythonResponse(error=AuthError("network_error", str(exc), 0))
123
+
124
+ if resp.status_code >= 400:
125
+ return SupythonResponse(error=_make_auth_error(resp))
126
+
127
+ token_resp = _parse_token_response(resp.json())
128
+ session = await self._save_session(token_resp)
129
+ self._emit(SIGNED_IN, session)
130
+ return SupythonResponse(data=token_resp)
131
+
132
+ async def sign_in_with_password(
133
+ self, email: str, password: str
134
+ ) -> SupythonResponse[TokenResponse]:
135
+ try:
136
+ resp = await self._http.post(
137
+ f"{self._url}/token",
138
+ json={"email": email, "password": password},
139
+ )
140
+ except httpx.HTTPError as exc:
141
+ return SupythonResponse(error=AuthError("network_error", str(exc), 0))
142
+
143
+ if resp.status_code >= 400:
144
+ return SupythonResponse(error=_make_auth_error(resp))
145
+
146
+ token_resp = _parse_token_response(resp.json())
147
+ session = await self._save_session(token_resp)
148
+ self._emit(SIGNED_IN, session)
149
+ return SupythonResponse(data=token_resp)
150
+
151
+ async def sign_out(self) -> SupythonResponse[None]:
152
+ refresh_token = self._client._refresh_token
153
+ if not refresh_token:
154
+ return SupythonResponse(data=None)
155
+
156
+ try:
157
+ resp = await self._http.post(
158
+ f"{self._url}/logout",
159
+ json={"refresh_token": refresh_token},
160
+ )
161
+ except httpx.HTTPError as exc:
162
+ return SupythonResponse(error=AuthError("network_error", str(exc), 0))
163
+
164
+ await self._clear_session()
165
+ self._emit(SIGNED_OUT, None)
166
+
167
+ if resp.status_code >= 400:
168
+ return SupythonResponse(error=_make_auth_error(resp))
169
+
170
+ return SupythonResponse(data=None)
171
+
172
+ async def refresh_session(self) -> SupythonResponse[TokenResponse]:
173
+ refresh_token = self._client._refresh_token
174
+ if not refresh_token:
175
+ err = AuthError("no_session", "No refresh token available", 401)
176
+ await self._clear_session()
177
+ self._emit(SIGNED_OUT, None)
178
+ return SupythonResponse(error=err)
179
+
180
+ try:
181
+ resp = await self._http.post(
182
+ f"{self._url}/refresh",
183
+ json={"refresh_token": refresh_token},
184
+ )
185
+ except httpx.HTTPError as exc:
186
+ return SupythonResponse(error=AuthError("network_error", str(exc), 0))
187
+
188
+ if resp.status_code >= 400:
189
+ await self._clear_session()
190
+ self._emit(SIGNED_OUT, None)
191
+ return SupythonResponse(error=_make_auth_error(resp))
192
+
193
+ token_resp = _parse_token_response(resp.json())
194
+ session = await self._save_session(token_resp)
195
+ self._emit(TOKEN_REFRESHED, session)
196
+ return SupythonResponse(data=token_resp)
197
+
198
+ async def get_user(self) -> SupythonResponse[UserResponse]:
199
+ access_token = self._client._access_token
200
+ if not access_token:
201
+ return SupythonResponse(error=AuthError("no_session", "Not authenticated", 401))
202
+
203
+ try:
204
+ resp = await self._http.get(
205
+ f"{self._url}/user",
206
+ headers={"Authorization": f"Bearer {access_token}"},
207
+ )
208
+ except httpx.HTTPError as exc:
209
+ return SupythonResponse(error=AuthError("network_error", str(exc), 0))
210
+
211
+ if resp.status_code >= 400:
212
+ return SupythonResponse(error=_make_auth_error(resp))
213
+
214
+ user = _parse_user_response(resp.json())
215
+ previous = self._client._user
216
+ self._client._user = user
217
+ if previous is not None and previous != user:
218
+ self._emit(USER_UPDATED, self.get_session())
219
+ return SupythonResponse(data=user)
220
+
221
+ def get_session(self) -> Session | None:
222
+ access_token = self._client._access_token
223
+ refresh_token = self._client._refresh_token
224
+ if not access_token:
225
+ return None
226
+ return Session(
227
+ access_token=access_token,
228
+ refresh_token=refresh_token or "",
229
+ expires_in=0,
230
+ user=self._client._user,
231
+ )
232
+
233
+ async def _save_session(self, token_resp: TokenResponse) -> Session:
234
+ self._client.set_session(
235
+ token_resp.access_token,
236
+ token_resp.refresh_token,
237
+ user=token_resp.user,
238
+ )
239
+ await self._client._persist_session()
240
+ return Session(
241
+ access_token=token_resp.access_token,
242
+ refresh_token=token_resp.refresh_token,
243
+ expires_in=token_resp.expires_in,
244
+ user=token_resp.user,
245
+ )
246
+
247
+ async def _clear_session(self) -> None:
248
+ self._client.clear_session()
249
+ await self._client._persist_session()
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ._auth import SIGNED_IN, AuthClient, UserResponse
6
+ from ._config import (
7
+ ClientOptions,
8
+ MemoryAuthStorage,
9
+ _build_auth_url,
10
+ _build_functions_url,
11
+ _build_rest_url,
12
+ _build_storage_url,
13
+ _build_ws_url,
14
+ )
15
+ from ._functions import FunctionsClient
16
+ from ._storage import StorageClient
17
+
18
+
19
+ class Client:
20
+ def __init__(
21
+ self,
22
+ supython_url: str,
23
+ anon_key: str = "",
24
+ *,
25
+ options: ClientOptions | None = None,
26
+ ) -> None:
27
+ self.base_url = supython_url.rstrip("/")
28
+ self._anon_key = anon_key
29
+ self._opts = options or ClientOptions()
30
+ self._access_token: str | None = None
31
+ self._refresh_token: str | None = None
32
+ self._user: UserResponse | None = None
33
+
34
+ if self._opts.auth.storage is None:
35
+ self._storage_backend = MemoryAuthStorage()
36
+ else:
37
+ self._storage_backend = self._opts.auth.storage
38
+
39
+ self.auth = AuthClient(self, _build_auth_url(self.base_url))
40
+ self.storage = StorageClient(_build_storage_url(self.base_url), self._anon_key, self)
41
+ self.functions = FunctionsClient(
42
+ _build_functions_url(self.base_url), self._anon_key, self
43
+ )
44
+
45
+ self._rest_url = _build_rest_url(self.base_url)
46
+ self._ws_url = _build_ws_url(self.base_url)
47
+
48
+ def set_session(
49
+ self,
50
+ access_token: str,
51
+ refresh_token: str,
52
+ *,
53
+ user: UserResponse | None = None,
54
+ ) -> None:
55
+ self._access_token = access_token
56
+ self._refresh_token = refresh_token
57
+ if user is not None:
58
+ self._user = user
59
+
60
+ def clear_session(self) -> None:
61
+ self._access_token = None
62
+ self._refresh_token = None
63
+ self._user = None
64
+
65
+ async def _persist_session(self) -> None:
66
+ if not self._opts.auth.persist_session:
67
+ return
68
+ # Persistence is best-effort: the in-memory session is the source of
69
+ # truth and a failed write should not break the auth response contract.
70
+ try:
71
+ if self._access_token and self._refresh_token:
72
+ await self._storage_backend.save(
73
+ {
74
+ "access_token": self._access_token,
75
+ "refresh_token": self._refresh_token,
76
+ }
77
+ )
78
+ else:
79
+ await self._storage_backend.clear()
80
+ except Exception:
81
+ pass
82
+
83
+ async def restore_session(self) -> bool:
84
+ try:
85
+ data = await self._storage_backend.load()
86
+ except Exception:
87
+ return False
88
+ if not data:
89
+ return False
90
+ access_token = data.get("access_token")
91
+ refresh_token = data.get("refresh_token")
92
+ if not access_token:
93
+ return False
94
+ self.set_session(access_token, refresh_token or "")
95
+ self.auth._emit(SIGNED_IN, self.auth.get_session())
96
+ return True
97
+
98
+ def _auth_headers(self) -> dict[str, str]:
99
+ headers: dict[str, str] = {}
100
+ if self._anon_key:
101
+ headers["apikey"] = self._anon_key
102
+ if self._access_token:
103
+ headers["Authorization"] = f"Bearer {self._access_token}"
104
+ return headers
105
+
106
+ def from_(self, table: str) -> Any:
107
+ try:
108
+ from postgrest import PostgrestClient
109
+ except ImportError as exc:
110
+ raise ImportError(
111
+ "PostgREST query builder requires postgrest-py. "
112
+ "Install with: pip install supython[client]"
113
+ ) from exc
114
+ return PostgrestClient(
115
+ self._rest_url, headers=self._auth_headers()
116
+ ).from_(table)
117
+
118
+ def rpc(self, fn_name: str, params: dict | None = None) -> Any:
119
+ try:
120
+ from postgrest import PostgrestClient
121
+ except ImportError as exc:
122
+ raise ImportError(
123
+ "PostgREST query builder requires postgrest-py. "
124
+ "Install with: pip install supython[client]"
125
+ ) from exc
126
+ return PostgrestClient(
127
+ self._rest_url, headers=self._auth_headers()
128
+ ).rpc(fn_name, params or {})
129
+
130
+ def channel(self, topic: str) -> Any:
131
+ try:
132
+ from realtime import RealtimeClient
133
+ except ImportError as exc:
134
+ raise ImportError(
135
+ "Realtime client requires the realtime package. "
136
+ "Install with: pip install supython[client]"
137
+ ) from exc
138
+ client = RealtimeClient(self._ws_url, params={"apikey": self._anon_key})
139
+ return client.channel(topic)
140
+
141
+ async def _auto_refresh(self) -> bool:
142
+ if not self._opts.auth.auto_refresh_token:
143
+ return False
144
+ result = await self.auth.refresh_session()
145
+ return result.error is None
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
7
+
8
+ if TYPE_CHECKING:
9
+ pass
10
+
11
+
12
+ @runtime_checkable
13
+ class AuthStorageBackend(Protocol):
14
+ async def load(self) -> dict[str, str] | None: ...
15
+ async def save(self, data: dict[str, str]) -> None: ...
16
+ async def clear(self) -> None: ...
17
+
18
+
19
+ class MemoryAuthStorage:
20
+ def __init__(self) -> None:
21
+ self._data: dict[str, str] | None = None
22
+
23
+ async def load(self) -> dict[str, str] | None:
24
+ return self._data
25
+
26
+ async def save(self, data: dict[str, str]) -> None:
27
+ self._data = dict(data)
28
+
29
+ async def clear(self) -> None:
30
+ self._data = None
31
+
32
+
33
+ class FileAuthStorage:
34
+ def __init__(self, path: Path) -> None:
35
+ self._path = path
36
+
37
+ async def load(self) -> dict[str, str] | None:
38
+ if not self._path.exists():
39
+ return None
40
+ text = self._path.read_text(encoding="utf-8").strip()
41
+ if not text:
42
+ return None
43
+ return json.loads(text)
44
+
45
+ async def save(self, data: dict[str, str]) -> None:
46
+ self._path.parent.mkdir(parents=True, exist_ok=True)
47
+ self._path.write_text(json.dumps(data), encoding="utf-8")
48
+
49
+ async def clear(self) -> None:
50
+ if self._path.exists():
51
+ self._path.write_text("", encoding="utf-8")
52
+
53
+
54
+ @dataclass
55
+ class AuthOptions:
56
+ storage: AuthStorageBackend | None = None
57
+ auto_refresh_token: bool = True
58
+ persist_session: bool = True
59
+
60
+
61
+ @dataclass
62
+ class ClientOptions:
63
+ auth: AuthOptions = field(default_factory=AuthOptions)
64
+
65
+
66
+ def _build_rest_url(base_url: str) -> str:
67
+ return f"{base_url}/rest/v1"
68
+
69
+
70
+ def _build_auth_url(base_url: str) -> str:
71
+ return f"{base_url}/auth/v1"
72
+
73
+
74
+ def _build_storage_url(base_url: str) -> str:
75
+ return f"{base_url}/storage/v1"
76
+
77
+
78
+ def _build_functions_url(base_url: str) -> str:
79
+ return f"{base_url}/functions"
80
+
81
+
82
+ def _build_ws_url(base_url: str) -> str:
83
+ return base_url.replace("http://", "ws://").replace("https://", "wss://") + "/realtime/v1"
84
+
85
+
86
+ def _parse_error_detail(body: Any) -> tuple[str, str]:
87
+ if isinstance(body, dict):
88
+ detail = body.get("detail", body)
89
+ if isinstance(detail, dict):
90
+ return detail.get("code", "unknown"), detail.get("message", str(body))
91
+ return "unknown", str(detail)
92
+ return "unknown", str(body)
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from ._auth import SupythonResponse
9
+ from ._config import _parse_error_detail
10
+
11
+
12
+ @dataclass
13
+ class FunctionsError:
14
+ code: str
15
+ message: str
16
+ status: int
17
+
18
+
19
+ def _make_functions_error(resp: httpx.Response) -> FunctionsError:
20
+ try:
21
+ body = resp.json()
22
+ except Exception:
23
+ return FunctionsError(
24
+ "network_error", resp.text or f"HTTP {resp.status_code}", resp.status_code
25
+ )
26
+ code, message = _parse_error_detail(body)
27
+ return FunctionsError(code, message, resp.status_code)
28
+
29
+
30
+ class FunctionsClient:
31
+ def __init__(self, base_url: str, anon_key: str, client: Any) -> None:
32
+ self._url = base_url
33
+ self._anon_key = anon_key
34
+ self._client = client
35
+ self._http = httpx.AsyncClient()
36
+
37
+ def _headers(self) -> dict[str, str]:
38
+ headers: dict[str, str] = {"Content-Type": "application/json"}
39
+ if self._anon_key:
40
+ headers["apikey"] = self._anon_key
41
+ access_token = self._client._access_token
42
+ if access_token:
43
+ headers["Authorization"] = f"Bearer {access_token}"
44
+ return headers
45
+
46
+ async def invoke(
47
+ self,
48
+ name: str,
49
+ *,
50
+ body: dict[str, Any] | None = None,
51
+ method: str = "POST",
52
+ ) -> SupythonResponse[Any]:
53
+ url = f"{self._url}/{name}"
54
+ try:
55
+ resp = await self._http.request(
56
+ method, url, json=body, headers=self._headers()
57
+ )
58
+ except httpx.HTTPError as exc:
59
+ return SupythonResponse(error=FunctionsError("network_error", str(exc), 0))
60
+
61
+ if resp.status_code >= 400:
62
+ return SupythonResponse(error=_make_functions_error(resp))
63
+
64
+ try:
65
+ data = resp.json()
66
+ except Exception:
67
+ data = resp.text
68
+
69
+ return SupythonResponse(data=data)