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 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__}")
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any