jotabase 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ .pytest_cache/
3
+ *.egg-info/
4
+ build/
5
+ .venv/
6
+ .jotabase/
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.5
2
+ Name: jotabase
3
+ Version: 0.7.0
4
+ Summary: Python client for jotaBase: read, write, local SQL with DuckDB and live changes.
5
+ Project-URL: Homepage, https://jotabase.com
6
+ Project-URL: Repository, https://github.com/jimv-dev/jbase
7
+ Author: jotaBase
8
+ License-Expression: ISC
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Topic :: Database
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: httpx>=0.27
14
+ Requires-Dist: pyarrow>=15
15
+ Requires-Dist: websockets>=13
16
+ Provides-Extra: pandas
17
+ Requires-Dist: pandas>=2.0; extra == 'pandas'
18
+ Provides-Extra: polars
19
+ Requires-Dist: polars>=1.0; extra == 'polars'
20
+ Provides-Extra: sql
21
+ Requires-Dist: duckdb>=1.1; extra == 'sql'
22
+ Provides-Extra: test
23
+ Requires-Dist: duckdb>=1.1; extra == 'test'
24
+ Requires-Dist: pandas>=2.0; extra == 'test'
25
+ Requires-Dist: polars>=1.0; extra == 'test'
26
+ Requires-Dist: pytest>=8; extra == 'test'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # jotabase
30
+
31
+ Python client for [jotaBase](https://jotabase.com): sync, query and update your jotaBase databases
32
+ from servers, scripts, notebooks and desktop apps.
33
+
34
+ - **Read and write** a database with a secret key (servers and scripts) or a publishable key and a
35
+ signed-in user (apps, where access rules apply).
36
+ - **Keep a copy on disk** that later syncs bring up to date, downloading only what changed.
37
+ - **Query it with SQL** through DuckDB, one table per collection, with results as pandas or Polars
38
+ tables.
39
+ - **React to changes** as they happen.
40
+
41
+ ```bash
42
+ pip install jotabase # read, write, local copy, live changes
43
+ pip install "jotabase[sql,pandas]" # plus SQL with DuckDB, and pandas
44
+ ```
45
+
46
+ Python 3.10 or newer.
47
+
48
+ ## Quick start
49
+
50
+ ```python
51
+ import jotabase
52
+
53
+ db = jotabase.connect("my-app", key="sk_live_...") # your database's secret key
54
+
55
+ # Write
56
+ db.put("tasks", {"title": "Write the report", "points": 3}, id="t1")
57
+ results = db.put_many("measurements", batch) # split under the 1 MB request limit
58
+ for r in results.failed: # refused by an access rule
59
+ print(r.id, r.error)
60
+ db.delete("tasks", "t1")
61
+
62
+ # Read everything, or only what changed since last time
63
+ changes = db.fetch_changes()
64
+ docs = changes.latest() # newest version of each, no deleted ones
65
+ later = db.fetch_changes(since=changes.checkpoint)
66
+
67
+ # Keep a copy in ./.jotabase/my-app/ and read it with no server
68
+ db.pull()
69
+ tasks = db.local().documents("tasks")
70
+
71
+ # SQL over the copy (pip install "jotabase[sql]")
72
+ df = db.sql("SELECT status, count(*) AS n FROM tasks GROUP BY status").df() # pandas
73
+ pl = db.sql("SELECT * FROM tasks WHERE points > ?", [3]).pl() # Polars
74
+
75
+ # React to changes as they happen (a background thread)
76
+ @db.on_change
77
+ def changed(change):
78
+ for doc in change.docs:
79
+ print(doc["id"], doc["data"])
80
+ ```
81
+
82
+ ## Keys and users
83
+
84
+ | You connect with | Who it is for | Access rules |
85
+ |---|---|---|
86
+ | `key="sk_..."` (secret) | servers, scripts, scheduled jobs | bypassed |
87
+ | `key="pk_..."` (publishable), then `db.sign_in(email, password)` | apps and desktop apps | applied to the user's roles |
88
+ | no key, `jotabase.connect("@handle/name")` | someone's public database | read-only, no live changes |
89
+
90
+ Never ship a secret key inside an app that runs on someone else's machine. Keys are created in the
91
+ dashboard at [app.jotabase.com](https://app.jotabase.com).
92
+
93
+ ## Errors
94
+
95
+ A refused request raises `jotabase.JotabaseError` with `.status` (401/403 means the key or the
96
+ login, anything else is worth a retry) and `.code`. A document an access rule refuses does **not**
97
+ raise: it comes back in `results.failed` with the reason, while the rest of the batch is written.
98
+
99
+ ## Links
100
+
101
+ - [jotaBase](https://jotabase.com) and the [dashboard](https://app.jotabase.com)
102
+ - [Design notes for this package](https://github.com/jimv-dev/jbase/blob/main/docs/PYTHON-CLIENT.md)
103
+ - The JavaScript client: [`@jotabase/client`](https://www.npmjs.com/package/@jotabase/client)
@@ -0,0 +1,75 @@
1
+ # jotabase
2
+
3
+ Python client for [jotaBase](https://jotabase.com): sync, query and update your jotaBase databases
4
+ from servers, scripts, notebooks and desktop apps.
5
+
6
+ - **Read and write** a database with a secret key (servers and scripts) or a publishable key and a
7
+ signed-in user (apps, where access rules apply).
8
+ - **Keep a copy on disk** that later syncs bring up to date, downloading only what changed.
9
+ - **Query it with SQL** through DuckDB, one table per collection, with results as pandas or Polars
10
+ tables.
11
+ - **React to changes** as they happen.
12
+
13
+ ```bash
14
+ pip install jotabase # read, write, local copy, live changes
15
+ pip install "jotabase[sql,pandas]" # plus SQL with DuckDB, and pandas
16
+ ```
17
+
18
+ Python 3.10 or newer.
19
+
20
+ ## Quick start
21
+
22
+ ```python
23
+ import jotabase
24
+
25
+ db = jotabase.connect("my-app", key="sk_live_...") # your database's secret key
26
+
27
+ # Write
28
+ db.put("tasks", {"title": "Write the report", "points": 3}, id="t1")
29
+ results = db.put_many("measurements", batch) # split under the 1 MB request limit
30
+ for r in results.failed: # refused by an access rule
31
+ print(r.id, r.error)
32
+ db.delete("tasks", "t1")
33
+
34
+ # Read everything, or only what changed since last time
35
+ changes = db.fetch_changes()
36
+ docs = changes.latest() # newest version of each, no deleted ones
37
+ later = db.fetch_changes(since=changes.checkpoint)
38
+
39
+ # Keep a copy in ./.jotabase/my-app/ and read it with no server
40
+ db.pull()
41
+ tasks = db.local().documents("tasks")
42
+
43
+ # SQL over the copy (pip install "jotabase[sql]")
44
+ df = db.sql("SELECT status, count(*) AS n FROM tasks GROUP BY status").df() # pandas
45
+ pl = db.sql("SELECT * FROM tasks WHERE points > ?", [3]).pl() # Polars
46
+
47
+ # React to changes as they happen (a background thread)
48
+ @db.on_change
49
+ def changed(change):
50
+ for doc in change.docs:
51
+ print(doc["id"], doc["data"])
52
+ ```
53
+
54
+ ## Keys and users
55
+
56
+ | You connect with | Who it is for | Access rules |
57
+ |---|---|---|
58
+ | `key="sk_..."` (secret) | servers, scripts, scheduled jobs | bypassed |
59
+ | `key="pk_..."` (publishable), then `db.sign_in(email, password)` | apps and desktop apps | applied to the user's roles |
60
+ | no key, `jotabase.connect("@handle/name")` | someone's public database | read-only, no live changes |
61
+
62
+ Never ship a secret key inside an app that runs on someone else's machine. Keys are created in the
63
+ dashboard at [app.jotabase.com](https://app.jotabase.com).
64
+
65
+ ## Errors
66
+
67
+ A refused request raises `jotabase.JotabaseError` with `.status` (401/403 means the key or the
68
+ login, anything else is worth a retry) and `.code`. A document an access rule refuses does **not**
69
+ raise: it comes back in `results.failed` with the reason, while the rest of the batch is written.
70
+
71
+ ## Links
72
+
73
+ - [jotaBase](https://jotabase.com) and the [dashboard](https://app.jotabase.com)
74
+ - [Design notes for this package](https://github.com/jimv-dev/jbase/blob/main/docs/PYTHON-CLIENT.md)
75
+ - The JavaScript client: [`@jotabase/client`](https://www.npmjs.com/package/@jotabase/client)
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "jotabase"
7
+ dynamic = ["version"]
8
+ description = "Python client for jotaBase: read, write, local SQL with DuckDB and live changes."
9
+ readme = "README.md"
10
+ license = "ISC"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "jotaBase" }]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3 :: Only",
16
+ "Topic :: Database",
17
+ ]
18
+ dependencies = [
19
+ "httpx>=0.27",
20
+ "pyarrow>=15",
21
+ "websockets>=13",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ sql = ["duckdb>=1.1"]
26
+ pandas = ["pandas>=2.0"]
27
+ polars = ["polars>=1.0"]
28
+ test = ["pytest>=8", "duckdb>=1.1", "pandas>=2.0", "polars>=1.0"]
29
+
30
+ [project.urls]
31
+ Homepage = "https://jotabase.com"
32
+ Repository = "https://github.com/jimv-dev/jbase"
33
+
34
+ # The version lives in one place, src/jotabase/__init__.py, and
35
+ # scripts/set-version.mjs writes it there together with the npm packages.
36
+ [tool.hatch.version]
37
+ path = "src/jotabase/__init__.py"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/jotabase"]
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests"]
@@ -0,0 +1,12 @@
1
+ """Python client for jotaBase.
2
+
3
+ Planned in docs/PYTHON-CLIENT.md.
4
+ """
5
+
6
+ # Set by scripts/set-version.mjs together with the npm packages. Never edit by hand.
7
+ __version__ = "0.7.0"
8
+
9
+ from .client import DEFAULT_URL, Database, connect # noqa: E402
10
+ from .errors import AuthError, JotabaseError # noqa: E402
11
+
12
+ __all__ = ["__version__", "connect", "Database", "DEFAULT_URL", "JotabaseError", "AuthError"]
@@ -0,0 +1,362 @@
1
+ """Connecting to one database, and signing in to it.
2
+
3
+ Three ways in, the same as the server's data plane (CLAUDE.md, "Control plane vs
4
+ data plane"; docs/PYTHON-CLIENT.md, "What it does" 1):
5
+
6
+ - a **secret key** (``sk_``): servers and scripts, bypasses access rules;
7
+ - a **publishable key** (``pk_``), optionally with an app user signed in through
8
+ ``sign_in``: subject to access rules;
9
+ - **no key**: a public ``@handle/name`` database, read-only.
10
+
11
+ The credential sent is the app user's token if one is signed in, else the key,
12
+ else nothing -- and with nothing, **no Authorization header at all**, never
13
+ ``Bearer None``, which the server would refuse as a bad credential instead of
14
+ reading as an anonymous request. That is the JS client's ``credential()`` rule.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections import deque
20
+ from typing import Any
21
+
22
+ import httpx
23
+
24
+ from . import __version__
25
+ from .errors import AuthError, JotabaseError
26
+
27
+ DEFAULT_URL = "https://api.jotabase.com"
28
+ DEFAULT_TIMEOUT = 30.0
29
+
30
+ # "Use the database's current credential" -- distinct from None, which sends none.
31
+ _CURRENT = object()
32
+
33
+
34
+ class Database:
35
+ """One jotaBase database. Create it with :func:`jotabase.connect`."""
36
+
37
+ def __init__(
38
+ self,
39
+ name: str,
40
+ *,
41
+ key: str | None = None,
42
+ token: str | None = None,
43
+ url: str = DEFAULT_URL,
44
+ timeout: float = DEFAULT_TIMEOUT,
45
+ transport: httpx.BaseTransport | None = None,
46
+ ):
47
+ name = name.strip().strip("/")
48
+ if not name:
49
+ raise ValueError("a database name is required")
50
+ if name.startswith("@") and name.count("/") != 1:
51
+ raise ValueError("a public database is addressed as '@handle/name'")
52
+ if not name.startswith("@") and "/" in name:
53
+ raise ValueError(
54
+ f"{name!r} is not a database name; a public one is written '@handle/name'"
55
+ )
56
+ self.name = name
57
+ self.url = url.rstrip("/")
58
+ self.key = key
59
+ self.token = token
60
+ self.user: dict[str, Any] | None = None
61
+ # Seqs of this Database's own writes, so a listener can leave them out.
62
+ self._own_seqs: set[int] = set()
63
+ self._own_order: deque[int] = deque()
64
+ self._listeners: list[Any] = []
65
+ self._http = httpx.Client(
66
+ base_url=self.url,
67
+ timeout=timeout,
68
+ transport=transport,
69
+ headers={"User-Agent": f"jotabase-python/{__version__}"},
70
+ )
71
+
72
+ # -- lifecycle -----------------------------------------------------------
73
+
74
+ def close(self) -> None:
75
+ for listener in list(self._listeners):
76
+ listener.stop()
77
+ for engine in self.__dict__.get("_sql", {}).values():
78
+ engine.close()
79
+ self._http.close()
80
+
81
+ def __enter__(self) -> "Database":
82
+ return self
83
+
84
+ def __exit__(self, *exc: object) -> None:
85
+ self.close()
86
+
87
+ def __repr__(self) -> str:
88
+ return f"<jotabase.Database {self.name!r} at {self.url} ({self.credential_kind})>"
89
+
90
+ # -- credentials ---------------------------------------------------------
91
+
92
+ @property
93
+ def is_public(self) -> bool:
94
+ """Addressed as ``@handle/name``: someone's published database."""
95
+ return self.name.startswith("@")
96
+
97
+ @property
98
+ def credential(self) -> str | None:
99
+ """What is sent as ``Authorization: Bearer``: token, else key, else nothing."""
100
+ return self.token or self.key or None
101
+
102
+ @property
103
+ def credential_kind(self) -> str:
104
+ """``app_user``, ``secret``, ``publishable`` or ``anonymous``, from what is held.
105
+
106
+ A local reading of the credential's shape; the server decides what it
107
+ really is. Useful for messages, not for access decisions.
108
+ """
109
+ if self.token:
110
+ return "app_user"
111
+ if self.key and self.key.startswith("sk_"):
112
+ return "secret"
113
+ if self.key and self.key.startswith("pk_"):
114
+ return "publishable"
115
+ if self.key:
116
+ return "unknown"
117
+ return "anonymous"
118
+
119
+ # -- app users (/_auth/*) ------------------------------------------------
120
+
121
+ def sign_up(self, email: str, password: str) -> dict[str, Any]:
122
+ """Create an app user (role ``member``) and, when a token comes back, sign in.
123
+
124
+ A database with confirmed email required returns
125
+ ``verification_required: True`` and no token: the user must open the
126
+ emailed link, then ``sign_in``.
127
+ """
128
+ body = self._auth_post("/_auth/signup", email, password)
129
+ self._take_session(body)
130
+ return body
131
+
132
+ def sign_in(self, email: str, password: str) -> dict[str, Any]:
133
+ """Sign an app user in; later requests carry their token and their roles.
134
+
135
+ Raises :class:`AuthError` -- ``code='email_not_verified'`` (403) when the
136
+ database requires a confirmed address and this one is not.
137
+ """
138
+ body = self._auth_post("/_auth/login", email, password)
139
+ self._take_session(body)
140
+ return body
141
+
142
+ def sign_out(self) -> None:
143
+ """Forget the app user's token; requests go back to using the key."""
144
+ self.token = None
145
+ self.user = None
146
+
147
+ def me(self) -> dict[str, Any]:
148
+ """The signed-in app user's id, email, roles and whether they are verified."""
149
+ if not self.token:
150
+ raise AuthError("no app user is signed in", status=None)
151
+ try:
152
+ return self._request("GET", "/_auth/me").json()
153
+ except JotabaseError as e:
154
+ raise AuthError(str(e), e.status, e.code) from None
155
+
156
+ def _auth_post(self, path: str, email: str, password: str) -> dict[str, Any]:
157
+ if not self.key:
158
+ raise AuthError("signing app users in needs the database's publishable key")
159
+ # The key, never a previous user's token: /_auth answers for the database.
160
+ try:
161
+ res = self._request(
162
+ "POST", path, json={"email": email, "password": password}, credential=self.key
163
+ )
164
+ except JotabaseError as e:
165
+ raise AuthError(str(e), e.status, e.code) from None
166
+ return res.json()
167
+
168
+ def _take_session(self, body: dict[str, Any]) -> None:
169
+ if body.get("token"):
170
+ self.token = body["token"]
171
+ self.user = body.get("user")
172
+
173
+ # -- reading -------------------------------------------------------------
174
+
175
+ def fetch_changes(self, since: int = 0, on_progress=None):
176
+ """Everything past ``since`` as documents, in apply order; stores nothing.
177
+
178
+ Returns :class:`jotabase.sync.Changes`: ``.items``, ``.checkpoint`` (pass
179
+ it as ``since`` next time) and ``.latest()`` (newest version per id,
180
+ deleted ones left out). The bring-your-own-store path, like the JS
181
+ client's ``fetchChanges``.
182
+ """
183
+ from .sync import fetch_changes
184
+
185
+ return fetch_changes(self, since, on_progress)
186
+
187
+ def list_changes(self, since: int = 0, files_since: int | None = None):
188
+ """One ``/_sync`` without fetching any file: segments, tail and stats."""
189
+ from .sync import list_changes
190
+
191
+ return list_changes(self, since, files_since)
192
+
193
+ def local(self, folder=None):
194
+ """The local copy on disk (default ``./.jotabase/<db>/``); see :class:`LocalStore`."""
195
+ from .local import LocalStore
196
+
197
+ if folder is None and getattr(self, "_local", None) is not None:
198
+ return self._local
199
+ store = LocalStore(self, folder)
200
+ if folder is None:
201
+ self._local = store
202
+ return store
203
+
204
+ def pull(self, folder=None, on_progress=None):
205
+ """Bring the local copy up to date; only what changed is downloaded."""
206
+ return self.local(folder).pull(on_progress)
207
+
208
+ def sql_engine(self, folder=None):
209
+ """The DuckDB engine over the local copy; see :class:`jotabase.sql.LocalSql`."""
210
+ from .sql import LocalSql
211
+
212
+ store = self.local(folder)
213
+ engines = self.__dict__.setdefault("_sql", {})
214
+ key = str(store.folder.resolve())
215
+ if key not in engines:
216
+ engines[key] = LocalSql(store)
217
+ return engines[key]
218
+
219
+ def sql(self, query: str, params=None, folder=None):
220
+ """SQL over the local copy: one view per collection. Call :meth:`pull` first.
221
+
222
+ >>> db.pull()
223
+ >>> db.sql("SELECT status, count(*) FROM tasks GROUP BY status").df()
224
+ """
225
+ return self.sql_engine(folder).sql(query, params)
226
+
227
+ # -- live changes --------------------------------------------------------
228
+
229
+ def on_change(self, handler=None, *, since=None, pull=False, include_own=False, on_error=None):
230
+ """Call ``handler(change)`` for every change, from a background thread.
231
+
232
+ ``change.docs`` are the new versions (tombstones included, ``deleted``
233
+ set), ``change.last_seq`` the server's seq. Starts from now, or from
234
+ ``since``. ``pull=True`` also brings the local copy (and ``db.sql``) up to
235
+ date before each call. Usable as a decorator::
236
+
237
+ @db.on_change
238
+ def changed(change):
239
+ for doc in change.docs: ...
240
+
241
+ Returns a :class:`jotabase.live.Listener`; ``.stop()`` ends it, and so
242
+ does ``db.close()``. Needs the ``websockets`` package.
243
+ """
244
+ from .live import Listener
245
+
246
+ def start(h):
247
+ listener = Listener(self, h, since=since, pull=pull, include_own=include_own,
248
+ on_error=on_error)
249
+ self._listeners.append(listener)
250
+ return listener
251
+
252
+ return start if handler is None else start(handler)
253
+
254
+ def _note_own_writes(self, seqs) -> None:
255
+ for s in seqs:
256
+ if s and s not in self._own_seqs:
257
+ self._own_seqs.add(s)
258
+ self._own_order.append(s)
259
+ while len(self._own_order) > 100_000:
260
+ self._own_seqs.discard(self._own_order.popleft())
261
+
262
+ # -- writing -------------------------------------------------------------
263
+
264
+ def put(self, collection: str | None, data: dict[str, Any], id: str | None = None):
265
+ """Create or replace one document; returns its :class:`WriteResult`.
266
+
267
+ The id is ``id``, else ``data["id"]``, else a new UUID. A document an
268
+ access rule refuses comes back with ``ok=False`` and the reason in
269
+ ``error`` -- check it; it is not raised.
270
+ """
271
+ from .push import make_doc, push
272
+
273
+ return push(self, [make_doc(collection, data, id)])[0]
274
+
275
+ def put_many(
276
+ self,
277
+ collection: str | None,
278
+ docs,
279
+ silent: bool = False,
280
+ on_progress=None,
281
+ ):
282
+ """Create or replace many documents, in as few requests as fit under 1 MB.
283
+
284
+ Returns :class:`WriteResults` (a list, in input order) with ``.ok`` and
285
+ ``.failed``. ``silent=True`` writes without notifying live listeners per
286
+ batch -- for bulk loads.
287
+ """
288
+ from .push import make_doc, push_many
289
+
290
+ return push_many(
291
+ self, (make_doc(collection, d) for d in docs), silent=silent, on_progress=on_progress
292
+ )
293
+
294
+ def delete(self, collection: str | None, id: str):
295
+ """Delete one document (a tombstone); returns its :class:`WriteResult`."""
296
+ from .push import make_doc, push
297
+
298
+ return push(self, [make_doc(collection, {}, id, deleted=True)])[0]
299
+
300
+ def push(self, docs: list[dict[str, Any]], silent: bool = False):
301
+ """Raw ``/_push`` of ready-made entries ``{id, data, deleted?, collection?}``, batched."""
302
+ from .push import push_many
303
+
304
+ return push_many(self, docs, silent=silent)
305
+
306
+ # -- transport -----------------------------------------------------------
307
+
308
+ def _request(
309
+ self,
310
+ method: str,
311
+ path: str,
312
+ *,
313
+ credential: Any = _CURRENT,
314
+ **kwargs: Any,
315
+ ) -> httpx.Response:
316
+ """One data-plane request to ``/{db}{path}``; raises on anything but 2xx."""
317
+ cred = self.credential if credential is _CURRENT else credential
318
+ headers = dict(kwargs.pop("headers", None) or {})
319
+ if cred:
320
+ headers["Authorization"] = f"Bearer {cred}"
321
+ target = path if path.startswith("/" + self.name + "/") else f"/{self.name}{path}"
322
+ try:
323
+ res = self._http.request(method, target, headers=headers, **kwargs)
324
+ except httpx.HTTPError as e:
325
+ raise JotabaseError(f"{method} {target} failed: {e}") from e
326
+ if res.is_success:
327
+ return res
328
+ raise _error_from(res)
329
+
330
+
331
+ def _error_from(res: httpx.Response) -> JotabaseError:
332
+ try:
333
+ data = res.json()
334
+ except ValueError:
335
+ data = {}
336
+ if not isinstance(data, dict):
337
+ data = {}
338
+ message = data.get("reason") or data.get("message") or data.get("error") or res.reason_phrase
339
+ code = data.get("error") if isinstance(data.get("error"), str) else None
340
+ return JotabaseError(f"{res.status_code}: {message}", res.status_code, code)
341
+
342
+
343
+ def connect(
344
+ name: str,
345
+ *,
346
+ key: str | None = None,
347
+ token: str | None = None,
348
+ url: str = DEFAULT_URL,
349
+ timeout: float = DEFAULT_TIMEOUT,
350
+ transport: httpx.BaseTransport | None = None,
351
+ ) -> Database:
352
+ """Open one database.
353
+
354
+ >>> db = jotabase.connect("my-app", key="sk_live_...") # a server or script
355
+ >>> db = jotabase.connect("my-app", key="pk_live_...") # an app, rules apply
356
+ >>> db = jotabase.connect("@jotabase/trips") # a public database
357
+
358
+ Nothing is sent until the first call that needs the server. ``url`` points at
359
+ another server (``http://localhost:8787`` for ``wrangler dev``); ``transport``
360
+ is for tests.
361
+ """
362
+ return Database(name, key=key, token=token, url=url, timeout=timeout, transport=transport)
@@ -0,0 +1,22 @@
1
+ """Errors raised by the client."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class JotabaseError(Exception):
7
+ """A request the server refused, or one that never reached it.
8
+
9
+ ``status`` is the HTTP status (``None`` when no response arrived), so a caller
10
+ can tell an expired or wrong credential (401/403) from a transient failure --
11
+ the same distinction the JS client's ``SyncError`` carries. ``code`` is the
12
+ server's ``error`` field when it sent one (``'email_not_verified'``, ...).
13
+ """
14
+
15
+ def __init__(self, message: str, status: int | None = None, code: str | None = None):
16
+ super().__init__(message)
17
+ self.status = status
18
+ self.code = code
19
+
20
+
21
+ class AuthError(JotabaseError):
22
+ """Sign-up, sign-in or identity failed (``/_auth/*``)."""