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.
- jotabase-0.7.0/.gitignore +6 -0
- jotabase-0.7.0/PKG-INFO +103 -0
- jotabase-0.7.0/README.md +75 -0
- jotabase-0.7.0/pyproject.toml +43 -0
- jotabase-0.7.0/src/jotabase/__init__.py +12 -0
- jotabase-0.7.0/src/jotabase/client.py +362 -0
- jotabase-0.7.0/src/jotabase/errors.py +22 -0
- jotabase-0.7.0/src/jotabase/live.py +244 -0
- jotabase-0.7.0/src/jotabase/local.py +341 -0
- jotabase-0.7.0/src/jotabase/push.py +164 -0
- jotabase-0.7.0/src/jotabase/segment.py +57 -0
- jotabase-0.7.0/src/jotabase/sql.py +194 -0
- jotabase-0.7.0/src/jotabase/sync.py +173 -0
- jotabase-0.7.0/src/jotabase/views.py +248 -0
- jotabase-0.7.0/tests/conftest.py +144 -0
- jotabase-0.7.0/tests/integration/__init__.py +0 -0
- jotabase-0.7.0/tests/integration/test_live_server.py +254 -0
- jotabase-0.7.0/tests/test_connect.py +159 -0
- jotabase-0.7.0/tests/test_live.py +241 -0
- jotabase-0.7.0/tests/test_local.py +350 -0
- jotabase-0.7.0/tests/test_package.py +6 -0
- jotabase-0.7.0/tests/test_push.py +131 -0
- jotabase-0.7.0/tests/test_segment_fixtures.py +41 -0
- jotabase-0.7.0/tests/test_sql.py +131 -0
- jotabase-0.7.0/tests/test_sql_fixtures.py +57 -0
- jotabase-0.7.0/tests/test_sync.py +144 -0
- jotabase-0.7.0/tests/test_version.py +27 -0
jotabase-0.7.0/PKG-INFO
ADDED
|
@@ -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)
|
jotabase-0.7.0/README.md
ADDED
|
@@ -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/*``)."""
|