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.
- supython/__init__.py +24 -0
- supython/admin/__init__.py +3 -0
- supython/admin/api/__init__.py +24 -0
- supython/admin/api/auth.py +118 -0
- supython/admin/api/auth_templates.py +67 -0
- supython/admin/api/auth_users.py +225 -0
- supython/admin/api/db.py +174 -0
- supython/admin/api/functions.py +92 -0
- supython/admin/api/jobs.py +192 -0
- supython/admin/api/ops.py +224 -0
- supython/admin/api/realtime.py +281 -0
- supython/admin/api/service_auth.py +49 -0
- supython/admin/api/service_auth_templates.py +83 -0
- supython/admin/api/service_auth_users.py +346 -0
- supython/admin/api/service_db.py +214 -0
- supython/admin/api/service_functions.py +287 -0
- supython/admin/api/service_jobs.py +282 -0
- supython/admin/api/service_ops.py +213 -0
- supython/admin/api/service_realtime.py +30 -0
- supython/admin/api/service_storage.py +220 -0
- supython/admin/api/storage.py +117 -0
- supython/admin/api/system.py +37 -0
- supython/admin/audit.py +29 -0
- supython/admin/deps.py +22 -0
- supython/admin/errors.py +16 -0
- supython/admin/schemas.py +310 -0
- supython/admin/session.py +52 -0
- supython/admin/spa.py +38 -0
- supython/admin/static/assets/Alert-dluGVkos.js +49 -0
- supython/admin/static/assets/Audit-Njung3HI.js +2 -0
- supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
- supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
- supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
- supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
- supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
- supython/admin/static/assets/Crons-B67vc39F.js +2 -0
- supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
- supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
- supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
- supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
- supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
- supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
- supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
- supython/admin/static/assets/Input-DppYTq9C.js +259 -0
- supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
- supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
- supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
- supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
- supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
- supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
- supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
- supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
- supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
- supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
- supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
- supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
- supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
- supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
- supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
- supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
- supython/admin/static/assets/Space-n5-XcguU.js +400 -0
- supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
- supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
- supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
- supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
- supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
- supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
- supython/admin/static/assets/Users-wzwajhlh.js +2 -0
- supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
- supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
- supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
- supython/admin/static/assets/get-Ca6unauB.js +2 -0
- supython/admin/static/assets/index-CeE6v959.js +951 -0
- supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
- supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
- supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
- supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
- supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
- supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
- supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
- supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
- supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
- supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
- supython/admin/static/favicon.svg +1 -0
- supython/admin/static/icons.svg +24 -0
- supython/admin/static/index.html +24 -0
- supython/app.py +162 -0
- supython/auth/__init__.py +3 -0
- supython/auth/_email_job.py +11 -0
- supython/auth/providers/__init__.py +34 -0
- supython/auth/providers/github.py +22 -0
- supython/auth/providers/google.py +19 -0
- supython/auth/providers/oauth.py +56 -0
- supython/auth/providers/registry.py +16 -0
- supython/auth/ratelimit.py +39 -0
- supython/auth/router.py +282 -0
- supython/auth/schemas.py +79 -0
- supython/auth/service.py +587 -0
- supython/backups/__init__.py +24 -0
- supython/backups/_backup_job.py +170 -0
- supython/backups/schemas.py +18 -0
- supython/backups/service.py +217 -0
- supython/body_size.py +184 -0
- supython/cli.py +1663 -0
- supython/client/__init__.py +67 -0
- supython/client/_auth.py +249 -0
- supython/client/_client.py +145 -0
- supython/client/_config.py +92 -0
- supython/client/_functions.py +69 -0
- supython/client/_storage.py +255 -0
- supython/client/py.typed +0 -0
- supython/db.py +151 -0
- supython/db_admin.py +8 -0
- supython/extensions.py +36 -0
- supython/functions/__init__.py +19 -0
- supython/functions/context.py +262 -0
- supython/functions/loader.py +307 -0
- supython/functions/router.py +228 -0
- supython/functions/schemas.py +50 -0
- supython/gen/__init__.py +5 -0
- supython/gen/_introspect.py +137 -0
- supython/gen/types_py.py +270 -0
- supython/gen/types_ts.py +365 -0
- supython/health.py +229 -0
- supython/hooks.py +117 -0
- supython/jobs/__init__.py +31 -0
- supython/jobs/backends.py +97 -0
- supython/jobs/context.py +58 -0
- supython/jobs/cron.py +152 -0
- supython/jobs/cron_inproc.py +119 -0
- supython/jobs/decorators.py +76 -0
- supython/jobs/registry.py +79 -0
- supython/jobs/router.py +136 -0
- supython/jobs/schemas.py +92 -0
- supython/jobs/service.py +311 -0
- supython/jobs/worker.py +219 -0
- supython/jwks.py +257 -0
- supython/keyset.py +279 -0
- supython/logging_config.py +291 -0
- supython/mail.py +33 -0
- supython/mailer.py +65 -0
- supython/migrate.py +81 -0
- supython/migrations/0001_extensions_and_roles.sql +46 -0
- supython/migrations/0002_auth_schema.sql +66 -0
- supython/migrations/0003_demo_todos.sql +42 -0
- supython/migrations/0004_auth_v0_2.sql +47 -0
- supython/migrations/0005_storage_schema.sql +117 -0
- supython/migrations/0006_realtime_schema.sql +206 -0
- supython/migrations/0007_jobs_schema.sql +254 -0
- supython/migrations/0008_jobs_last_error.sql +56 -0
- supython/migrations/0009_auth_rate_limits.sql +33 -0
- supython/migrations/0010_worker_heartbeat.sql +14 -0
- supython/migrations/0011_admin_schema.sql +45 -0
- supython/migrations/0012_auth_banned_until.sql +10 -0
- supython/migrations/0013_email_templates.sql +19 -0
- supython/migrations/0014_realtime_payload_warning.sql +96 -0
- supython/migrations/0015_backups_schema.sql +14 -0
- supython/passwords.py +15 -0
- supython/realtime/__init__.py +6 -0
- supython/realtime/broker.py +814 -0
- supython/realtime/protocol.py +234 -0
- supython/realtime/router.py +184 -0
- supython/realtime/schemas.py +207 -0
- supython/realtime/service.py +261 -0
- supython/realtime/topics.py +175 -0
- supython/realtime/websocket.py +586 -0
- supython/scaffold/__init__.py +5 -0
- supython/scaffold/init_project.py +144 -0
- supython/scaffold/templates/Caddyfile.tmpl +4 -0
- supython/scaffold/templates/README.md.tmpl +22 -0
- supython/scaffold/templates/apps_hooks.py.tmpl +11 -0
- supython/scaffold/templates/apps_jobs.py.tmpl +8 -0
- supython/scaffold/templates/asgi.py.tmpl +14 -0
- supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
- supython/scaffold/templates/docker-compose.yml.tmpl +45 -0
- supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
- supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
- supython/scaffold/templates/env.example.tmpl +168 -0
- supython/scaffold/templates/functions_README.md.tmpl +21 -0
- supython/scaffold/templates/gitignore.tmpl +14 -0
- supython/scaffold/templates/manage.py.tmpl +11 -0
- supython/scaffold/templates/migrations/.gitkeep +0 -0
- supython/scaffold/templates/package_init.py.tmpl +1 -0
- supython/scaffold/templates/settings.py.tmpl +31 -0
- supython/secretset.py +347 -0
- supython/security_headers.py +78 -0
- supython/settings.py +244 -0
- supython/settings_module.py +117 -0
- supython/storage/__init__.py +5 -0
- supython/storage/backends.py +392 -0
- supython/storage/router.py +341 -0
- supython/storage/schemas.py +50 -0
- supython/storage/service.py +445 -0
- supython/storage/signing.py +119 -0
- supython/tokens.py +85 -0
- supython-0.1.0.dist-info/METADATA +756 -0
- supython-0.1.0.dist-info/RECORD +200 -0
- supython-0.1.0.dist-info/WHEEL +4 -0
- supython-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|
supython/client/_auth.py
ADDED
|
@@ -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)
|