instantdb 0.0.1__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.
- instantdb/__init__.py +30 -0
- instantdb/_auth.py +161 -0
- instantdb/_client.py +231 -0
- instantdb/_errors.py +23 -0
- instantdb/_http.py +84 -0
- instantdb/_rooms.py +35 -0
- instantdb/_storage.py +66 -0
- instantdb/_subscribe.py +238 -0
- instantdb/_transact.py +114 -0
- instantdb/_version.py +16 -0
- instantdb-0.0.1.dist-info/METADATA +111 -0
- instantdb-0.0.1.dist-info/RECORD +13 -0
- instantdb-0.0.1.dist-info/WHEEL +4 -0
instantdb/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Python SDK for InstantDB.
|
|
2
|
+
|
|
3
|
+
Quick start:
|
|
4
|
+
|
|
5
|
+
from instantdb import init, id
|
|
6
|
+
|
|
7
|
+
db = init(app_id="...", admin_token="...")
|
|
8
|
+
result = db.query({"goals": {"todos": {}}})
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from ._client import Instant, init
|
|
14
|
+
from ._errors import InstantAPIError, InstantError
|
|
15
|
+
from ._subscribe import Subscription
|
|
16
|
+
from ._transact import TransactionChunk, id, lookup, tx
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Instant",
|
|
21
|
+
"InstantAPIError",
|
|
22
|
+
"InstantError",
|
|
23
|
+
"Subscription",
|
|
24
|
+
"TransactionChunk",
|
|
25
|
+
"__version__",
|
|
26
|
+
"id",
|
|
27
|
+
"init",
|
|
28
|
+
"lookup",
|
|
29
|
+
"tx",
|
|
30
|
+
]
|
instantdb/_auth.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from urllib.parse import urlencode
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ._http import Config, authorized_headers, json_request
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Auth:
|
|
12
|
+
"""Auth admin operations: magic codes, tokens, users."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, client: httpx.Client, config: Config):
|
|
15
|
+
self._client = client
|
|
16
|
+
self._config = config
|
|
17
|
+
|
|
18
|
+
def generate_magic_code(self, email: str) -> dict[str, Any]:
|
|
19
|
+
"""Generate a magic code without sending it (use your own email provider)."""
|
|
20
|
+
return json_request(
|
|
21
|
+
self._client,
|
|
22
|
+
"POST",
|
|
23
|
+
f"{self._config.api_uri}/admin/magic_code?app_id={self._config.app_id}",
|
|
24
|
+
headers=authorized_headers(self._config),
|
|
25
|
+
json={"email": email},
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def send_magic_code(self, email: str) -> dict[str, Any]:
|
|
29
|
+
"""Send a magic code to the given email using Instant's email provider."""
|
|
30
|
+
return json_request(
|
|
31
|
+
self._client,
|
|
32
|
+
"POST",
|
|
33
|
+
f"{self._config.api_uri}/admin/send_magic_code?app_id={self._config.app_id}",
|
|
34
|
+
headers=authorized_headers(self._config),
|
|
35
|
+
json={"email": email},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def check_magic_code(
|
|
39
|
+
self,
|
|
40
|
+
email: str,
|
|
41
|
+
code: str,
|
|
42
|
+
extra_fields: dict[str, Any] | None = None,
|
|
43
|
+
) -> tuple[dict[str, Any], bool]:
|
|
44
|
+
"""Verify a magic code and return (user, created)."""
|
|
45
|
+
body: dict[str, Any] = {"email": email, "code": code}
|
|
46
|
+
if extra_fields:
|
|
47
|
+
body["extra-fields"] = extra_fields
|
|
48
|
+
response = json_request(
|
|
49
|
+
self._client,
|
|
50
|
+
"POST",
|
|
51
|
+
f"{self._config.api_uri}/admin/verify_magic_code?app_id={self._config.app_id}",
|
|
52
|
+
headers=authorized_headers(self._config),
|
|
53
|
+
json=body,
|
|
54
|
+
)
|
|
55
|
+
return response["user"], bool(response.get("created"))
|
|
56
|
+
|
|
57
|
+
def create_token(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
email: str | None = None,
|
|
61
|
+
id: str | None = None,
|
|
62
|
+
) -> str:
|
|
63
|
+
"""Create a refresh token for the given user. Creates the user if missing."""
|
|
64
|
+
if email is None and id is None:
|
|
65
|
+
raise ValueError("create_token requires email= or id=")
|
|
66
|
+
body: dict[str, str] = {}
|
|
67
|
+
if email is not None:
|
|
68
|
+
body["email"] = email
|
|
69
|
+
if id is not None:
|
|
70
|
+
body["id"] = id
|
|
71
|
+
response = json_request(
|
|
72
|
+
self._client,
|
|
73
|
+
"POST",
|
|
74
|
+
f"{self._config.api_uri}/admin/refresh_tokens?app_id={self._config.app_id}",
|
|
75
|
+
headers=authorized_headers(self._config),
|
|
76
|
+
json=body,
|
|
77
|
+
)
|
|
78
|
+
return response["user"]["refresh_token"]
|
|
79
|
+
|
|
80
|
+
def verify_token(self, token: str) -> dict[str, Any]:
|
|
81
|
+
"""Verify a refresh token and return the associated user."""
|
|
82
|
+
response = json_request(
|
|
83
|
+
self._client,
|
|
84
|
+
"POST",
|
|
85
|
+
f"{self._config.api_uri}/runtime/auth/verify_refresh_token?app_id={self._config.app_id}",
|
|
86
|
+
headers={"content-type": "application/json"},
|
|
87
|
+
json={"app-id": self._config.app_id, "refresh-token": token},
|
|
88
|
+
)
|
|
89
|
+
return response["user"]
|
|
90
|
+
|
|
91
|
+
def get_user(
|
|
92
|
+
self,
|
|
93
|
+
*,
|
|
94
|
+
email: str | None = None,
|
|
95
|
+
id: str | None = None,
|
|
96
|
+
refresh_token: str | None = None,
|
|
97
|
+
) -> dict[str, Any]:
|
|
98
|
+
"""Look up an app user by email, id, or refresh token."""
|
|
99
|
+
params = self._build_user_params(email=email, id=id, refresh_token=refresh_token)
|
|
100
|
+
qs = urlencode(params)
|
|
101
|
+
response = json_request(
|
|
102
|
+
self._client,
|
|
103
|
+
"GET",
|
|
104
|
+
f"{self._config.api_uri}/admin/users?app_id={self._config.app_id}&{qs}",
|
|
105
|
+
headers=authorized_headers(self._config),
|
|
106
|
+
)
|
|
107
|
+
return response["user"]
|
|
108
|
+
|
|
109
|
+
def delete_user(
|
|
110
|
+
self,
|
|
111
|
+
*,
|
|
112
|
+
email: str | None = None,
|
|
113
|
+
id: str | None = None,
|
|
114
|
+
refresh_token: str | None = None,
|
|
115
|
+
) -> dict[str, Any]:
|
|
116
|
+
"""Delete an app user by email, id, or refresh token. Does not delete their data."""
|
|
117
|
+
params = self._build_user_params(email=email, id=id, refresh_token=refresh_token)
|
|
118
|
+
qs = urlencode(params)
|
|
119
|
+
response = json_request(
|
|
120
|
+
self._client,
|
|
121
|
+
"DELETE",
|
|
122
|
+
f"{self._config.api_uri}/admin/users?app_id={self._config.app_id}&{qs}",
|
|
123
|
+
headers=authorized_headers(self._config),
|
|
124
|
+
)
|
|
125
|
+
return response["deleted"]
|
|
126
|
+
|
|
127
|
+
def sign_out(
|
|
128
|
+
self,
|
|
129
|
+
*,
|
|
130
|
+
email: str | None = None,
|
|
131
|
+
id: str | None = None,
|
|
132
|
+
refresh_token: str | None = None,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Invalidate all tokens for the given user."""
|
|
135
|
+
body = self._build_user_params(email=email, id=id, refresh_token=refresh_token)
|
|
136
|
+
json_request(
|
|
137
|
+
self._client,
|
|
138
|
+
"POST",
|
|
139
|
+
f"{self._config.api_uri}/admin/sign_out?app_id={self._config.app_id}",
|
|
140
|
+
headers=authorized_headers(self._config),
|
|
141
|
+
json=body,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _build_user_params(
|
|
146
|
+
*,
|
|
147
|
+
email: str | None,
|
|
148
|
+
id: str | None,
|
|
149
|
+
refresh_token: str | None,
|
|
150
|
+
) -> dict[str, str]:
|
|
151
|
+
provided = {
|
|
152
|
+
"email": email,
|
|
153
|
+
"id": id,
|
|
154
|
+
"refresh_token": refresh_token,
|
|
155
|
+
}
|
|
156
|
+
params = {k: v for k, v in provided.items() if v is not None}
|
|
157
|
+
if not params:
|
|
158
|
+
raise ValueError("Must provide one of email=, id=, or refresh_token=")
|
|
159
|
+
if len(params) > 1:
|
|
160
|
+
raise ValueError("Provide only one of email=, id=, or refresh_token=")
|
|
161
|
+
return params
|
instantdb/_client.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
import warnings
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ._auth import Auth
|
|
10
|
+
from ._http import Config, authorized_headers, json_request
|
|
11
|
+
from ._rooms import Rooms
|
|
12
|
+
from ._storage import Storage
|
|
13
|
+
from ._subscribe import SubscribeCallback, Subscription
|
|
14
|
+
from ._transact import TransactionChunk, _TxRoot, chunks_to_steps
|
|
15
|
+
|
|
16
|
+
_DEFAULT_API_URI = "https://api.instantdb.com"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def init(
|
|
20
|
+
*,
|
|
21
|
+
app_id: str,
|
|
22
|
+
admin_token: str | None = None,
|
|
23
|
+
api_uri: str | None = None,
|
|
24
|
+
) -> Instant:
|
|
25
|
+
"""Create a new Instant admin client.
|
|
26
|
+
|
|
27
|
+
Visit https://instantdb.com/dash to get your app id and admin token.
|
|
28
|
+
"""
|
|
29
|
+
cleaned_app_id = app_id.strip() if app_id else app_id
|
|
30
|
+
if not _is_valid_uuid(cleaned_app_id):
|
|
31
|
+
warnings.warn(
|
|
32
|
+
f"Instant Admin DB must be initialized with a valid app_id. Received: {app_id!r}",
|
|
33
|
+
stacklevel=2,
|
|
34
|
+
)
|
|
35
|
+
config = Config(
|
|
36
|
+
app_id=cleaned_app_id,
|
|
37
|
+
admin_token=admin_token.strip() if admin_token else None,
|
|
38
|
+
api_uri=(api_uri or _DEFAULT_API_URI).strip(),
|
|
39
|
+
)
|
|
40
|
+
return Instant(config)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _is_valid_uuid(s: str | None) -> bool:
|
|
44
|
+
if not s:
|
|
45
|
+
return False
|
|
46
|
+
try:
|
|
47
|
+
uuid.UUID(s)
|
|
48
|
+
return True
|
|
49
|
+
except ValueError:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Instant:
|
|
54
|
+
"""The primary entrypoint for interacting with an Instant app.
|
|
55
|
+
|
|
56
|
+
Construct via `init(app_id=..., admin_token=...)` rather than directly.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
config: Config,
|
|
62
|
+
impersonation: dict[str, Any] | None = None,
|
|
63
|
+
client: httpx.Client | None = None,
|
|
64
|
+
):
|
|
65
|
+
self._config = config
|
|
66
|
+
self._impersonation = impersonation
|
|
67
|
+
self._client = client or httpx.Client(timeout=httpx.Timeout(60.0, connect=10.0))
|
|
68
|
+
self.tx = _TxRoot()
|
|
69
|
+
self.auth = Auth(self._client, config)
|
|
70
|
+
self.storage = Storage(self._client, config, impersonation)
|
|
71
|
+
self.rooms = Rooms(self._client, config)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def config(self) -> Config:
|
|
75
|
+
return self._config
|
|
76
|
+
|
|
77
|
+
def as_user(
|
|
78
|
+
self,
|
|
79
|
+
*,
|
|
80
|
+
email: str | None = None,
|
|
81
|
+
token: str | None = None,
|
|
82
|
+
guest: bool | None = None,
|
|
83
|
+
) -> Instant:
|
|
84
|
+
"""Scope subsequent operations to a user (or guest) for permissions purposes.
|
|
85
|
+
|
|
86
|
+
Pass exactly one of email=, token=, or guest=True.
|
|
87
|
+
"""
|
|
88
|
+
opts = _impersonation_opts(email=email, token=token, guest=guest)
|
|
89
|
+
return Instant(self._config, impersonation=opts, client=self._client)
|
|
90
|
+
|
|
91
|
+
def query(
|
|
92
|
+
self,
|
|
93
|
+
query: dict[str, Any],
|
|
94
|
+
*,
|
|
95
|
+
rule_params: dict[str, Any] | None = None,
|
|
96
|
+
) -> dict[str, Any]:
|
|
97
|
+
"""Run a one-shot InstaQL query."""
|
|
98
|
+
prepared = {"$$ruleParams": rule_params, **query} if rule_params is not None else query
|
|
99
|
+
return json_request(
|
|
100
|
+
self._client,
|
|
101
|
+
"POST",
|
|
102
|
+
f"{self._config.api_uri}/admin/query?app_id={self._config.app_id}",
|
|
103
|
+
headers=authorized_headers(self._config, self._impersonation),
|
|
104
|
+
json={"query": prepared, "inference?": False},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def transact(
|
|
108
|
+
self,
|
|
109
|
+
chunks: TransactionChunk | list[TransactionChunk],
|
|
110
|
+
) -> dict[str, Any]:
|
|
111
|
+
"""Apply one or more transaction chunks atomically."""
|
|
112
|
+
return json_request(
|
|
113
|
+
self._client,
|
|
114
|
+
"POST",
|
|
115
|
+
f"{self._config.api_uri}/admin/transact?app_id={self._config.app_id}",
|
|
116
|
+
headers=authorized_headers(self._config, self._impersonation),
|
|
117
|
+
json={"steps": chunks_to_steps(chunks)},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def subscribe_query(
|
|
121
|
+
self,
|
|
122
|
+
query: dict[str, Any],
|
|
123
|
+
callback: SubscribeCallback | None = None,
|
|
124
|
+
*,
|
|
125
|
+
rule_params: dict[str, Any] | None = None,
|
|
126
|
+
) -> Subscription:
|
|
127
|
+
"""Open a live SSE subscription to a query.
|
|
128
|
+
|
|
129
|
+
With no `callback`, the returned object is iterable - blocking until the
|
|
130
|
+
next payload arrives. With a `callback`, payloads are dispatched on a
|
|
131
|
+
background daemon thread. Call `.close()` (or use as a context manager)
|
|
132
|
+
to end the subscription.
|
|
133
|
+
"""
|
|
134
|
+
prepared = {"$$ruleParams": rule_params, **query} if rule_params is not None else query
|
|
135
|
+
return Subscription(
|
|
136
|
+
config=self._config,
|
|
137
|
+
query=prepared,
|
|
138
|
+
impersonation=self._impersonation,
|
|
139
|
+
callback=callback,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def debug_query(
|
|
143
|
+
self,
|
|
144
|
+
query: dict[str, Any],
|
|
145
|
+
*,
|
|
146
|
+
rules: dict[str, Any] | None = None,
|
|
147
|
+
rule_params: dict[str, Any] | None = None,
|
|
148
|
+
ip: str | None = None,
|
|
149
|
+
origin: str | None = None,
|
|
150
|
+
) -> dict[str, Any]:
|
|
151
|
+
"""Run a query and also return permissions-check results for each row.
|
|
152
|
+
|
|
153
|
+
Requires `as_user` context since permissions are user-scoped.
|
|
154
|
+
"""
|
|
155
|
+
prepared = {"$$ruleParams": rule_params, **query} if rule_params is not None else query
|
|
156
|
+
body: dict[str, Any] = {
|
|
157
|
+
"query": prepared,
|
|
158
|
+
"rules-override": rules,
|
|
159
|
+
"inference?": False,
|
|
160
|
+
}
|
|
161
|
+
if ip is not None:
|
|
162
|
+
body["ip-override"] = ip
|
|
163
|
+
if origin is not None:
|
|
164
|
+
body["origin-override"] = origin
|
|
165
|
+
response = json_request(
|
|
166
|
+
self._client,
|
|
167
|
+
"POST",
|
|
168
|
+
f"{self._config.api_uri}/admin/query_perms_check?app_id={self._config.app_id}",
|
|
169
|
+
headers=authorized_headers(self._config, self._impersonation),
|
|
170
|
+
json=body,
|
|
171
|
+
)
|
|
172
|
+
return {
|
|
173
|
+
"result": response.get("result"),
|
|
174
|
+
"check_results": response.get("check-results"),
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
def debug_transact(
|
|
178
|
+
self,
|
|
179
|
+
chunks: TransactionChunk | list[TransactionChunk],
|
|
180
|
+
*,
|
|
181
|
+
rules: dict[str, Any] | None = None,
|
|
182
|
+
ip: str | None = None,
|
|
183
|
+
origin: str | None = None,
|
|
184
|
+
) -> dict[str, Any]:
|
|
185
|
+
"""Dry-run a transaction and return permissions-check results.
|
|
186
|
+
|
|
187
|
+
Does not commit. Requires `as_user` context.
|
|
188
|
+
"""
|
|
189
|
+
body: dict[str, Any] = {
|
|
190
|
+
"steps": chunks_to_steps(chunks),
|
|
191
|
+
"rules-override": rules,
|
|
192
|
+
}
|
|
193
|
+
if ip is not None:
|
|
194
|
+
body["ip-override"] = ip
|
|
195
|
+
if origin is not None:
|
|
196
|
+
body["origin-override"] = origin
|
|
197
|
+
return json_request(
|
|
198
|
+
self._client,
|
|
199
|
+
"POST",
|
|
200
|
+
f"{self._config.api_uri}/admin/transact_perms_check?app_id={self._config.app_id}",
|
|
201
|
+
headers=authorized_headers(self._config, self._impersonation),
|
|
202
|
+
json=body,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def close(self) -> None:
|
|
206
|
+
"""Close the underlying HTTP client."""
|
|
207
|
+
self._client.close()
|
|
208
|
+
|
|
209
|
+
def __enter__(self) -> Instant:
|
|
210
|
+
return self
|
|
211
|
+
|
|
212
|
+
def __exit__(self, *_exc: Any) -> None:
|
|
213
|
+
self.close()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _impersonation_opts(
|
|
217
|
+
*,
|
|
218
|
+
email: str | None,
|
|
219
|
+
token: str | None,
|
|
220
|
+
guest: bool | None,
|
|
221
|
+
) -> dict[str, Any]:
|
|
222
|
+
provided = sum(x is not None for x in (email, token, guest))
|
|
223
|
+
if provided == 0:
|
|
224
|
+
raise ValueError("as_user requires one of email=, token=, or guest=True")
|
|
225
|
+
if provided > 1:
|
|
226
|
+
raise ValueError("as_user accepts only one of email=, token=, or guest=True")
|
|
227
|
+
if email is not None:
|
|
228
|
+
return {"email": email}
|
|
229
|
+
if token is not None:
|
|
230
|
+
return {"token": token}
|
|
231
|
+
return {"guest": bool(guest)}
|
instantdb/_errors.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class InstantError(Exception):
|
|
7
|
+
"""Base class for all Instant SDK errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InstantAPIError(InstantError):
|
|
11
|
+
"""Raised when the Instant API returns a non-2xx response."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, status: int, body: Any, message: str | None = None):
|
|
14
|
+
self.status = status
|
|
15
|
+
self.body = body
|
|
16
|
+
super().__init__(message or self._format_message())
|
|
17
|
+
|
|
18
|
+
def _format_message(self) -> str:
|
|
19
|
+
if isinstance(self.body, dict):
|
|
20
|
+
msg = self.body.get("message")
|
|
21
|
+
if msg:
|
|
22
|
+
return str(msg)
|
|
23
|
+
return f"Instant API error ({self.status}): {self.body!r}"
|
instantdb/_http.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ._errors import InstantAPIError
|
|
8
|
+
from ._version import __version__
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Config:
|
|
12
|
+
"""Resolved client configuration. Internal use only."""
|
|
13
|
+
|
|
14
|
+
__slots__ = ("app_id", "admin_token", "api_uri")
|
|
15
|
+
|
|
16
|
+
def __init__(self, app_id: str, admin_token: str | None, api_uri: str):
|
|
17
|
+
self.app_id = app_id
|
|
18
|
+
self.admin_token = admin_token
|
|
19
|
+
self.api_uri = api_uri.rstrip("/")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def authorized_headers(
|
|
23
|
+
config: Config,
|
|
24
|
+
impersonation: dict[str, Any] | None = None,
|
|
25
|
+
) -> dict[str, str]:
|
|
26
|
+
_validate_auth(config, impersonation)
|
|
27
|
+
headers: dict[str, str] = {
|
|
28
|
+
"content-type": "application/json",
|
|
29
|
+
"app-id": config.app_id,
|
|
30
|
+
"Instant-Admin-Version": __version__,
|
|
31
|
+
"Instant-Core-Version": __version__,
|
|
32
|
+
}
|
|
33
|
+
if config.admin_token:
|
|
34
|
+
headers["authorization"] = f"Bearer {config.admin_token}"
|
|
35
|
+
if impersonation:
|
|
36
|
+
if "email" in impersonation:
|
|
37
|
+
headers["as-email"] = str(impersonation["email"])
|
|
38
|
+
elif "token" in impersonation:
|
|
39
|
+
headers["as-token"] = str(impersonation["token"])
|
|
40
|
+
elif impersonation.get("guest"):
|
|
41
|
+
headers["as-guest"] = "true"
|
|
42
|
+
return headers
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _validate_auth(config: Config, impersonation: dict[str, Any] | None) -> None:
|
|
46
|
+
if impersonation and ("token" in impersonation or "guest" in impersonation):
|
|
47
|
+
return
|
|
48
|
+
if config.admin_token:
|
|
49
|
+
return
|
|
50
|
+
if impersonation and "email" in impersonation:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
"Admin token required. To impersonate users with an email you must pass "
|
|
53
|
+
"`admin_token` to `init`."
|
|
54
|
+
)
|
|
55
|
+
raise ValueError(
|
|
56
|
+
"Admin token required. To run this operation pass `admin_token` to `init`, "
|
|
57
|
+
"or use `db.as_user`."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def json_request(
|
|
62
|
+
client: httpx.Client,
|
|
63
|
+
method: str,
|
|
64
|
+
url: str,
|
|
65
|
+
*,
|
|
66
|
+
headers: dict[str, str],
|
|
67
|
+
json: Any = None,
|
|
68
|
+
content: bytes | None = None,
|
|
69
|
+
) -> Any:
|
|
70
|
+
"""Make an HTTP request and return parsed JSON. Raises InstantAPIError on non-2xx."""
|
|
71
|
+
response = client.request(
|
|
72
|
+
method,
|
|
73
|
+
url,
|
|
74
|
+
headers=headers,
|
|
75
|
+
json=json if content is None else None,
|
|
76
|
+
content=content,
|
|
77
|
+
)
|
|
78
|
+
if 200 <= response.status_code < 300:
|
|
79
|
+
return response.json() if response.content else None
|
|
80
|
+
try:
|
|
81
|
+
body: Any = response.json()
|
|
82
|
+
except ValueError:
|
|
83
|
+
body = {"type": None, "message": response.text}
|
|
84
|
+
raise InstantAPIError(status=response.status_code, body=body)
|
instantdb/_rooms.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ._http import Config, authorized_headers, json_request
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Rooms:
|
|
12
|
+
"""Room operations: presence."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, client: httpx.Client, config: Config):
|
|
15
|
+
self._client = client
|
|
16
|
+
self._config = config
|
|
17
|
+
|
|
18
|
+
def get_presence(self, room_type: str, room_id: str) -> dict[str, Any]:
|
|
19
|
+
"""Get the current presence data for a room.
|
|
20
|
+
|
|
21
|
+
Returns a mapping of `peer_id` to `{"data": ..., "peer-id": ..., "user": ...}`.
|
|
22
|
+
"""
|
|
23
|
+
url = (
|
|
24
|
+
f"{self._config.api_uri}/admin/rooms/presence"
|
|
25
|
+
f"?app_id={self._config.app_id}"
|
|
26
|
+
f"&room-type={quote(room_type)}"
|
|
27
|
+
f"&room-id={quote(room_id)}"
|
|
28
|
+
)
|
|
29
|
+
response = json_request(
|
|
30
|
+
self._client,
|
|
31
|
+
"GET",
|
|
32
|
+
url,
|
|
33
|
+
headers=authorized_headers(self._config),
|
|
34
|
+
)
|
|
35
|
+
return response.get("sessions") or {}
|
instantdb/_storage.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import IO, Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ._http import Config, authorized_headers, json_request
|
|
9
|
+
|
|
10
|
+
FileLike = bytes | bytearray | memoryview | IO[bytes] | Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Storage:
|
|
14
|
+
"""File storage operations."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
client: httpx.Client,
|
|
19
|
+
config: Config,
|
|
20
|
+
impersonation: dict[str, Any] | None = None,
|
|
21
|
+
):
|
|
22
|
+
self._client = client
|
|
23
|
+
self._config = config
|
|
24
|
+
self._impersonation = impersonation
|
|
25
|
+
|
|
26
|
+
def upload_file(
|
|
27
|
+
self,
|
|
28
|
+
path: str,
|
|
29
|
+
file: FileLike,
|
|
30
|
+
*,
|
|
31
|
+
content_type: str | None = None,
|
|
32
|
+
content_disposition: str | None = None,
|
|
33
|
+
) -> dict[str, Any]:
|
|
34
|
+
"""Upload a file to the given path.
|
|
35
|
+
|
|
36
|
+
`file` can be bytes, a binary file-like object, or a `pathlib.Path`.
|
|
37
|
+
"""
|
|
38
|
+
headers = authorized_headers(self._config, self._impersonation)
|
|
39
|
+
headers["path"] = path
|
|
40
|
+
headers.pop("content-type", None)
|
|
41
|
+
if content_type:
|
|
42
|
+
headers["content-type"] = content_type
|
|
43
|
+
if content_disposition:
|
|
44
|
+
headers["content-disposition"] = content_disposition
|
|
45
|
+
|
|
46
|
+
content = _read_file(file)
|
|
47
|
+
return json_request(
|
|
48
|
+
self._client,
|
|
49
|
+
"PUT",
|
|
50
|
+
f"{self._config.api_uri}/admin/storage/upload?app_id={self._config.app_id}",
|
|
51
|
+
headers=headers,
|
|
52
|
+
content=content,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _read_file(file: FileLike) -> bytes:
|
|
57
|
+
if isinstance(file, (bytes, bytearray, memoryview)):
|
|
58
|
+
return bytes(file)
|
|
59
|
+
if isinstance(file, Path):
|
|
60
|
+
return file.read_bytes()
|
|
61
|
+
if hasattr(file, "read"):
|
|
62
|
+
data = file.read()
|
|
63
|
+
if isinstance(data, str):
|
|
64
|
+
raise TypeError("upload_file expects bytes, got a text-mode file")
|
|
65
|
+
return bytes(data)
|
|
66
|
+
raise TypeError(f"Unsupported file type: {type(file).__name__}")
|
instantdb/_subscribe.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Callable, Iterator
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from httpx_sse import EventSource
|
|
12
|
+
|
|
13
|
+
from ._errors import InstantAPIError
|
|
14
|
+
from ._http import Config, authorized_headers
|
|
15
|
+
from ._version import __version__
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
SubscribeCallback = Callable[[dict[str, Any]], None]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _format_page_info(raw: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
23
|
+
if not raw:
|
|
24
|
+
return None
|
|
25
|
+
out: dict[str, Any] = {}
|
|
26
|
+
for etype, info in raw.items():
|
|
27
|
+
out[etype] = {
|
|
28
|
+
"start_cursor": info.get("start-cursor"),
|
|
29
|
+
"end_cursor": info.get("end-cursor"),
|
|
30
|
+
"has_next_page": info.get("has-next-page?"),
|
|
31
|
+
"has_previous_page": info.get("has-previous-page?"),
|
|
32
|
+
}
|
|
33
|
+
return out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Subscription:
|
|
37
|
+
"""A live subscription to an InstaQL query, backed by SSE.
|
|
38
|
+
|
|
39
|
+
Iterate (`for payload in sub`) to consume payloads synchronously, or pass a
|
|
40
|
+
`callback=` to receive them on a background thread. Either way, call
|
|
41
|
+
`sub.close()` to stop the subscription. Works as a context manager:
|
|
42
|
+
|
|
43
|
+
with db.subscribe_query({"todos": {}}) as sub:
|
|
44
|
+
for payload in sub:
|
|
45
|
+
...
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
config: Config,
|
|
51
|
+
query: dict[str, Any],
|
|
52
|
+
impersonation: dict[str, Any] | None,
|
|
53
|
+
callback: SubscribeCallback | None = None,
|
|
54
|
+
):
|
|
55
|
+
self._config = config
|
|
56
|
+
self._query = query
|
|
57
|
+
self._headers = authorized_headers(config, impersonation)
|
|
58
|
+
self._callback = callback
|
|
59
|
+
self._closed = False
|
|
60
|
+
self._ready_state: str = "connecting"
|
|
61
|
+
self._session_info: dict[str, str] | None = None
|
|
62
|
+
self._client: httpx.Client | None = None
|
|
63
|
+
self._response_cm: Any = None
|
|
64
|
+
self._started = False
|
|
65
|
+
self._lock = threading.Lock()
|
|
66
|
+
|
|
67
|
+
if callback is not None:
|
|
68
|
+
threading.Thread(target=self._run_callback, daemon=True).start()
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def ready_state(self) -> str:
|
|
72
|
+
return self._ready_state
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def is_closed(self) -> bool:
|
|
76
|
+
return self._ready_state == "closed"
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def session_info(self) -> dict[str, str] | None:
|
|
80
|
+
return self._session_info
|
|
81
|
+
|
|
82
|
+
def __iter__(self) -> Iterator[dict[str, Any]]:
|
|
83
|
+
return self._stream()
|
|
84
|
+
|
|
85
|
+
def __enter__(self) -> Subscription:
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
def __exit__(self, *_exc: Any) -> None:
|
|
89
|
+
self.close()
|
|
90
|
+
|
|
91
|
+
def close(self) -> None:
|
|
92
|
+
with self._lock:
|
|
93
|
+
if self._closed:
|
|
94
|
+
return
|
|
95
|
+
self._closed = True
|
|
96
|
+
self._ready_state = "closed"
|
|
97
|
+
if self._response_cm is not None:
|
|
98
|
+
with contextlib.suppress(Exception):
|
|
99
|
+
self._response_cm.__exit__(None, None, None)
|
|
100
|
+
if self._client is not None:
|
|
101
|
+
with contextlib.suppress(Exception):
|
|
102
|
+
self._client.close()
|
|
103
|
+
|
|
104
|
+
def _run_callback(self) -> None:
|
|
105
|
+
assert self._callback is not None
|
|
106
|
+
try:
|
|
107
|
+
for payload in self._stream():
|
|
108
|
+
if self._closed:
|
|
109
|
+
return
|
|
110
|
+
try:
|
|
111
|
+
self._callback(payload)
|
|
112
|
+
except Exception:
|
|
113
|
+
logger.exception("Error in subscribe_query callback")
|
|
114
|
+
except Exception:
|
|
115
|
+
logger.exception("Subscribe stream terminated unexpectedly")
|
|
116
|
+
|
|
117
|
+
def _stream(self) -> Iterator[dict[str, Any]]:
|
|
118
|
+
with self._lock:
|
|
119
|
+
if self._started:
|
|
120
|
+
raise RuntimeError(
|
|
121
|
+
"Subscription has already started. Create a new subscription to iterate again."
|
|
122
|
+
)
|
|
123
|
+
self._started = True
|
|
124
|
+
if self._closed:
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
self._client = httpx.Client(timeout=httpx.Timeout(None, connect=10.0))
|
|
128
|
+
body = json.dumps(
|
|
129
|
+
{
|
|
130
|
+
"query": self._query,
|
|
131
|
+
"inference?": False,
|
|
132
|
+
"versions": {
|
|
133
|
+
"@instantdb/admin": __version__,
|
|
134
|
+
"@instantdb/core": __version__,
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
url = f"{self._config.api_uri}/admin/subscribe-query?app_id={self._config.app_id}"
|
|
139
|
+
sse_headers = {**self._headers, "accept": "text/event-stream"}
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
self._response_cm = self._client.stream(
|
|
143
|
+
"POST",
|
|
144
|
+
url,
|
|
145
|
+
headers=sse_headers,
|
|
146
|
+
content=body,
|
|
147
|
+
)
|
|
148
|
+
response = self._response_cm.__enter__()
|
|
149
|
+
except httpx.HTTPError as e:
|
|
150
|
+
self._ready_state = "closed"
|
|
151
|
+
yield self._error_payload(
|
|
152
|
+
InstantAPIError(
|
|
153
|
+
status=0,
|
|
154
|
+
body={"type": None, "message": str(e)},
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
if response.status_code != 200:
|
|
160
|
+
try:
|
|
161
|
+
error_body: Any = json.loads(response.read())
|
|
162
|
+
except ValueError:
|
|
163
|
+
error_body = {"type": None, "message": response.text}
|
|
164
|
+
self._ready_state = "closed"
|
|
165
|
+
yield self._error_payload(InstantAPIError(status=response.status_code, body=error_body))
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
self._ready_state = "open"
|
|
169
|
+
try:
|
|
170
|
+
for sse in EventSource(response).iter_sse():
|
|
171
|
+
if self._closed:
|
|
172
|
+
return
|
|
173
|
+
if not sse.data:
|
|
174
|
+
continue
|
|
175
|
+
try:
|
|
176
|
+
msg = json.loads(sse.data)
|
|
177
|
+
except ValueError:
|
|
178
|
+
continue
|
|
179
|
+
payload = self._handle_message(msg)
|
|
180
|
+
if payload is not None:
|
|
181
|
+
yield payload
|
|
182
|
+
except httpx.HTTPError as e:
|
|
183
|
+
if not self._closed:
|
|
184
|
+
yield self._error_payload(
|
|
185
|
+
InstantAPIError(
|
|
186
|
+
status=0,
|
|
187
|
+
body={"type": None, "message": str(e)},
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
finally:
|
|
191
|
+
self._ready_state = "closed"
|
|
192
|
+
self.close()
|
|
193
|
+
|
|
194
|
+
def _handle_message(self, msg: dict[str, Any]) -> dict[str, Any] | None:
|
|
195
|
+
op = msg.get("op")
|
|
196
|
+
if op == "sse-init":
|
|
197
|
+
self._session_info = {
|
|
198
|
+
"machine_id": msg.get("machine-id", ""),
|
|
199
|
+
"session_id": msg.get("session-id", ""),
|
|
200
|
+
}
|
|
201
|
+
return None
|
|
202
|
+
if op == "add-query-ok":
|
|
203
|
+
return {
|
|
204
|
+
"type": "ok",
|
|
205
|
+
"data": msg.get("result"),
|
|
206
|
+
"page_info": _format_page_info((msg.get("result-meta") or {}).get("page-info")),
|
|
207
|
+
"session_info": self._session_info,
|
|
208
|
+
}
|
|
209
|
+
if op == "refresh-ok":
|
|
210
|
+
computations = msg.get("computations") or []
|
|
211
|
+
if computations:
|
|
212
|
+
first = computations[0]
|
|
213
|
+
return {
|
|
214
|
+
"type": "ok",
|
|
215
|
+
"data": first.get("instaql-result"),
|
|
216
|
+
"page_info": _format_page_info(
|
|
217
|
+
(first.get("result-meta") or {}).get("page-info")
|
|
218
|
+
),
|
|
219
|
+
"session_info": self._session_info,
|
|
220
|
+
}
|
|
221
|
+
return None
|
|
222
|
+
if op == "error":
|
|
223
|
+
return self._error_payload(
|
|
224
|
+
InstantAPIError(
|
|
225
|
+
status=msg.get("status") or 0,
|
|
226
|
+
body=msg,
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
def _error_payload(self, error: InstantAPIError) -> dict[str, Any]:
|
|
232
|
+
return {
|
|
233
|
+
"type": "error",
|
|
234
|
+
"error": error,
|
|
235
|
+
"ready_state": self._ready_state,
|
|
236
|
+
"is_closed": self.is_closed,
|
|
237
|
+
"session_info": self._session_info,
|
|
238
|
+
}
|
instantdb/_transact.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
_LOOKUP_PREFIX = "lookup__"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def id() -> str:
|
|
11
|
+
"""Generate a new UUID for use as an entity id."""
|
|
12
|
+
return str(uuid.uuid4())
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def lookup(attribute: str, value: Any) -> str:
|
|
16
|
+
"""Build a lookup ref for use in place of an entity id."""
|
|
17
|
+
return f"{_LOOKUP_PREFIX}{attribute}__{json.dumps(value)}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_lookup(k: str) -> bool:
|
|
21
|
+
return isinstance(k, str) and k.startswith(_LOOKUP_PREFIX)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse_lookup(k: str) -> list[Any]:
|
|
25
|
+
parts = k.split("__")
|
|
26
|
+
_, attribute, *v_parts = parts
|
|
27
|
+
return [attribute, json.loads("__".join(v_parts))]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TransactionChunk:
|
|
31
|
+
"""A chain of pending tx operations targeting one entity."""
|
|
32
|
+
|
|
33
|
+
__slots__ = ("_etype", "_eid", "_ops")
|
|
34
|
+
|
|
35
|
+
def __init__(self, etype: str, eid: Any, ops: list[list[Any]]):
|
|
36
|
+
self._etype = etype
|
|
37
|
+
self._eid = eid
|
|
38
|
+
self._ops = ops
|
|
39
|
+
|
|
40
|
+
def update(self, args: dict[str, Any], opts: dict[str, Any] | None = None) -> TransactionChunk:
|
|
41
|
+
return self._append("update", args, opts)
|
|
42
|
+
|
|
43
|
+
def create(self, args: dict[str, Any], opts: dict[str, Any] | None = None) -> TransactionChunk:
|
|
44
|
+
return self._append("create", args, opts)
|
|
45
|
+
|
|
46
|
+
def link(self, args: dict[str, Any]) -> TransactionChunk:
|
|
47
|
+
return self._append("link", args)
|
|
48
|
+
|
|
49
|
+
def unlink(self, args: dict[str, Any]) -> TransactionChunk:
|
|
50
|
+
return self._append("unlink", args)
|
|
51
|
+
|
|
52
|
+
def delete(self) -> TransactionChunk:
|
|
53
|
+
return self._append("delete", {})
|
|
54
|
+
|
|
55
|
+
def merge(self, args: dict[str, Any], opts: dict[str, Any] | None = None) -> TransactionChunk:
|
|
56
|
+
return self._append("merge", args, opts)
|
|
57
|
+
|
|
58
|
+
def rule_params(self, args: dict[str, Any]) -> TransactionChunk:
|
|
59
|
+
return self._append("ruleParams", args)
|
|
60
|
+
|
|
61
|
+
def _append(
|
|
62
|
+
self,
|
|
63
|
+
action: str,
|
|
64
|
+
args: dict[str, Any],
|
|
65
|
+
opts: dict[str, Any] | None = None,
|
|
66
|
+
) -> TransactionChunk:
|
|
67
|
+
op: list[Any] = [action, self._etype, self._eid, args]
|
|
68
|
+
if opts is not None:
|
|
69
|
+
op.append(opts)
|
|
70
|
+
return TransactionChunk(self._etype, self._eid, [*self._ops, op])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _EtypeChunk:
|
|
74
|
+
__slots__ = ("_etype",)
|
|
75
|
+
|
|
76
|
+
def __init__(self, etype: str):
|
|
77
|
+
self._etype = etype
|
|
78
|
+
|
|
79
|
+
def __getitem__(self, eid: Any) -> TransactionChunk:
|
|
80
|
+
if _is_lookup(eid):
|
|
81
|
+
return TransactionChunk(self._etype, _parse_lookup(eid), [])
|
|
82
|
+
return TransactionChunk(self._etype, eid, [])
|
|
83
|
+
|
|
84
|
+
def lookup(self, attribute: str, value: Any) -> TransactionChunk:
|
|
85
|
+
return TransactionChunk(self._etype, [attribute, value], [])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _TxRoot:
|
|
89
|
+
"""Entrypoint for the tx builder. Indexes/attributes produce namespace chunks."""
|
|
90
|
+
|
|
91
|
+
def __getattr__(self, etype: str) -> _EtypeChunk:
|
|
92
|
+
return _EtypeChunk(etype)
|
|
93
|
+
|
|
94
|
+
def __getitem__(self, etype: str) -> _EtypeChunk:
|
|
95
|
+
return _EtypeChunk(etype)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
tx = _TxRoot()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_ops(chunk: TransactionChunk) -> list[list[Any]]:
|
|
102
|
+
return list(chunk._ops)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def chunks_to_steps(
|
|
106
|
+
chunks: TransactionChunk | list[TransactionChunk],
|
|
107
|
+
) -> list[list[Any]]:
|
|
108
|
+
"""Flatten a chunk or list of chunks into the wire format expected by the server."""
|
|
109
|
+
if isinstance(chunks, TransactionChunk):
|
|
110
|
+
return get_ops(chunks)
|
|
111
|
+
steps: list[list[Any]] = []
|
|
112
|
+
for chunk in chunks:
|
|
113
|
+
steps.extend(get_ops(chunk))
|
|
114
|
+
return steps
|
instantdb/_version.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Single source of truth for the package version.
|
|
2
|
+
|
|
3
|
+
The version is read from the package metadata, which hatchling stamps at build
|
|
4
|
+
time from `client/packages/version/src/version.ts` so the Python SDK stays
|
|
5
|
+
locked to the JS SDK family's version.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from importlib.metadata import PackageNotFoundError
|
|
11
|
+
from importlib.metadata import version as _pkg_version
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
__version__ = _pkg_version("instantdb")
|
|
15
|
+
except PackageNotFoundError:
|
|
16
|
+
__version__ = "0.0.0+unknown"
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: instantdb
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Python SDK for InstantDB, the Modern Firebase
|
|
5
|
+
Project-URL: Homepage, https://instantdb.com
|
|
6
|
+
Project-URL: Documentation, https://instantdb.com/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/instantdb/instant
|
|
8
|
+
Project-URL: Issues, https://github.com/instantdb/instant/issues
|
|
9
|
+
Author-email: Instant <founders@instantdb.com>
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
Keywords: database,graph,instant,instantdb,realtime
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Database
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: httpx-sse>=0.4
|
|
25
|
+
Requires-Dist: httpx>=0.27
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
<p align="center">
|
|
33
|
+
<a href="https://instantdb.com">
|
|
34
|
+
<img alt="Shows the Instant logo" src="https://instantdb.com/img/icon/android-chrome-512x512.png" width="10%">
|
|
35
|
+
</a>
|
|
36
|
+
<h1 align="center">instantdb</h1>
|
|
37
|
+
</p>
|
|
38
|
+
|
|
39
|
+
<p align="center">
|
|
40
|
+
<a href="https://discord.com/invite/VU53p7uQcE">
|
|
41
|
+
<img height=20 src="https://img.shields.io/discord/1031957483243188235" />
|
|
42
|
+
</a>
|
|
43
|
+
<img src="https://img.shields.io/github/stars/instantdb/instant" alt="stars">
|
|
44
|
+
</p>
|
|
45
|
+
|
|
46
|
+
<p align="center">
|
|
47
|
+
<a href="https://www.instantdb.com/docs/backend">Get Started</a> ·
|
|
48
|
+
<a href="https://instantdb.com/examples">Examples</a> ·
|
|
49
|
+
<a href="https://www.instantdb.com/docs/backend">Docs</a> ·
|
|
50
|
+
<a href="https://discord.com/invite/VU53p7uQcE">Discord</a>
|
|
51
|
+
</p>
|
|
52
|
+
|
|
53
|
+
The Python SDK for [Instant](https://instantdb.com). This is a server SDK
|
|
54
|
+
(analogous to `@instantdb/admin`) for running scripts, agents, data pipelines,
|
|
55
|
+
and backend services against Instant from Python.
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install instantdb
|
|
61
|
+
# or
|
|
62
|
+
uv add instantdb
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Quick start
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from instantdb import init, id
|
|
69
|
+
|
|
70
|
+
db = init(app_id="...", admin_token="...")
|
|
71
|
+
|
|
72
|
+
# Query data
|
|
73
|
+
result = db.query({"goals": {"todos": {}}})
|
|
74
|
+
print(result["goals"])
|
|
75
|
+
|
|
76
|
+
# Write data
|
|
77
|
+
goal_id = id()
|
|
78
|
+
db.transact([
|
|
79
|
+
db.tx.goals[goal_id].update({"title": "Get fit"}),
|
|
80
|
+
db.tx.todos[id()].update({"title": "Go on a run"}),
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
# Subscribe to live changes
|
|
84
|
+
for payload in db.subscribe_query({"goals": {}}):
|
|
85
|
+
if payload["type"] == "error":
|
|
86
|
+
print("error:", payload["error"])
|
|
87
|
+
break
|
|
88
|
+
print("data:", payload["data"])
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
See the [backend docs](https://www.instantdb.com/docs/backend) for the full
|
|
92
|
+
admin API reference. The Python SDK mirrors that surface 1:1, with naming
|
|
93
|
+
Pythonized to `snake_case`.
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
Clone the [repo](https://github.com/instantdb/instant). From `client/packages/python/`:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
make install # pip install -e ".[dev]"
|
|
101
|
+
make check # ruff + mypy + pytest (~40 unit tests)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Pure-logic only - covers tx builder, validators, header construction. For
|
|
105
|
+
end-to-end testing against a real backend, use the sandbox at
|
|
106
|
+
[`client/sandbox/admin-sdk-python/`](../../sandbox/admin-sdk-python/).
|
|
107
|
+
|
|
108
|
+
## Questions?
|
|
109
|
+
|
|
110
|
+
If you have any questions, feel free to drop us a line on our
|
|
111
|
+
[Discord](https://discord.com/invite/VU53p7uQcE).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
instantdb/__init__.py,sha256=CfzR935XZMFYnu2UmZILIyDox94YCKDQ4eU_0RUbeVE,611
|
|
2
|
+
instantdb/_auth.py,sha256=zc8tdzI-dOixlSsnGgUI5v6zpx3GHG2UJb_4BB788BM,5568
|
|
3
|
+
instantdb/_client.py,sha256=bh8IX7Gv0D5vBUjzkaQwrF0nTU4K25M53SMJ3YVS95M,7330
|
|
4
|
+
instantdb/_errors.py,sha256=c5hSvfljRMJiPnfzx6IpCi2X40B2RakCJ7c3y0a1VsY,678
|
|
5
|
+
instantdb/_http.py,sha256=nmA0iwhq2t2fKh2367skOmsSR-YhHnftAHj-NJoIhAc,2547
|
|
6
|
+
instantdb/_rooms.py,sha256=drXlNdjE9T2Zeqi6_NGWu5USOdKjDQTwkdGATPKoo54,992
|
|
7
|
+
instantdb/_storage.py,sha256=cxQXsJ2-zND2D5MH6gypaYr2xpPtgXPaBAUEEQ2HZ6c,1908
|
|
8
|
+
instantdb/_subscribe.py,sha256=4pqzNfBoZdluVGqu94trcTK0Y9h56NEz1YzH2ZiKW9g,7778
|
|
9
|
+
instantdb/_transact.py,sha256=aPteTZAY70Y4-dXFUlQpVQPfGedYdoC21XhJkAGrfr0,3369
|
|
10
|
+
instantdb/_version.py,sha256=p8qkgd_X1hsJYDnkCG0hyN2hqmi3MyGhsCqElbPM5Ps,506
|
|
11
|
+
instantdb-0.0.1.dist-info/METADATA,sha256=6xdz5i9J5qvQHG_9Gwm59dtr0jSM3eOyggMQBJwgu3k,3579
|
|
12
|
+
instantdb-0.0.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
13
|
+
instantdb-0.0.1.dist-info/RECORD,,
|