sqlite-ursula 0.11.3.dev0__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.
- sqlite_ursula/__init__.py +77 -0
- sqlite_ursula/__main__.py +7 -0
- sqlite_ursula/_args.py +39 -0
- sqlite_ursula/_connection.py +368 -0
- sqlite_ursula/_control.py +199 -0
- sqlite_ursula/_doctor.py +37 -0
- sqlite_ursula/_events.py +477 -0
- sqlite_ursula/_gate.py +136 -0
- sqlite_ursula/_host.py +142 -0
- sqlite_ursula/_open.py +432 -0
- sqlite_ursula/errors.py +171 -0
- sqlite_ursula/langgraph.py +474 -0
- sqlite_ursula/py.typed +0 -0
- sqlite_ursula-0.11.3.dev0.dist-info/METADATA +183 -0
- sqlite_ursula-0.11.3.dev0.dist-info/RECORD +17 -0
- sqlite_ursula-0.11.3.dev0.dist-info/WHEEL +4 -0
- sqlite_ursula-0.11.3.dev0.dist-info/licenses/LICENSE +201 -0
sqlite_ursula/_host.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""The host: where the extension is, whether this Python can load it, and loading it.
|
|
2
|
+
|
|
3
|
+
The extension loads into the stdlib ``sqlite3`` module's SQLite, never into a SQLite of its own:
|
|
4
|
+
it registers the "ursula" VFS as that SQLite's default, so every ``sqlite3.connect(path)`` of a
|
|
5
|
+
bound database in the process goes through it, a framework's included. A Python can host it when
|
|
6
|
+
its ``sqlite3`` loads extensions and its SQLite is new enough. The extension says which SQLite is
|
|
7
|
+
new enough: its refusal to load is the one floor, and this module turns it into
|
|
8
|
+
``host_unsupported`` with the fix.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sqlite3
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Literal
|
|
19
|
+
|
|
20
|
+
from .errors import UrsulaSetupError
|
|
21
|
+
|
|
22
|
+
#: Where the README explains the hosts, with the Docker recipes.
|
|
23
|
+
HOSTS_URL = "https://github.com/tonbo-io/ursula/tree/main/clients/sqlite-ursula-python#hosts"
|
|
24
|
+
|
|
25
|
+
#: ``host_unsupported``'s fix for a Python whose ``sqlite3`` cannot host the extension. It names no
|
|
26
|
+
#: SQLite version: the extension's refusal, in the error's message, names the one it needs.
|
|
27
|
+
OTHER_PYTHON = (
|
|
28
|
+
"use a Python whose sqlite3 module loads extensions and runs a SQLite the extension "
|
|
29
|
+
"supports: a uv-managed CPython (uv python install 3.13, then uv venv --managed-python), "
|
|
30
|
+
"Homebrew's or conda-forge's, or the python:3.13-alpine image in Docker instead of "
|
|
31
|
+
f"python:3.13-slim ({HOSTS_URL}). python -m sqlite_ursula checks a Python."
|
|
32
|
+
)
|
|
33
|
+
#: ``host_unsupported``'s fix on a platform the extension has no VFS for.
|
|
34
|
+
UNDER_WSL = (
|
|
35
|
+
"run the application under WSL, with a Python there that can host the extension "
|
|
36
|
+
f"({HOSTS_URL}). python -m sqlite_ursula checks a Python."
|
|
37
|
+
)
|
|
38
|
+
#: ``host_unsupported``'s fix for an install that carries no library.
|
|
39
|
+
OWN_LIBRARY = (
|
|
40
|
+
"set SQLITE_URSULA_VFS to a library built from clients/sqlite-vfs, or install on a platform "
|
|
41
|
+
"with a prebuilt one: Linux x64 and arm64 with glibc, Linux x64 with musl, macOS arm64. In "
|
|
42
|
+
"Docker on arm64, use a uv-managed CPython on a glibc base, as recipes/Dockerfile.uv does "
|
|
43
|
+
f"({HOSTS_URL})."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
#: The library's file name on each platform the extension supports.
|
|
47
|
+
FILES = {"linux": "libsqlite_ursula_vfs.so", "darwin": "libsqlite_ursula_vfs.dylib"}
|
|
48
|
+
|
|
49
|
+
#: The start of every refusal of the extension to load into a host's SQLite (clients/sqlite-vfs
|
|
50
|
+
#: `GateRefusal`, design: SQL functions), which its unit tests pin.
|
|
51
|
+
REFUSAL = "sqlite-ursula-vfs needs "
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Library:
|
|
56
|
+
"""The extension this process loads."""
|
|
57
|
+
|
|
58
|
+
path: str
|
|
59
|
+
#: ``SQLITE_URSULA_VFS`` when that names it, else ``bundled``: the one in the installed wheel.
|
|
60
|
+
source: Literal["SQLITE_URSULA_VFS", "bundled"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def unsupported(why: str, fix: str) -> UrsulaSetupError:
|
|
64
|
+
"""``host_unsupported``: this Python cannot host the extension, for the reason ``why``, and
|
|
65
|
+
``fix`` says what to do about that reason."""
|
|
66
|
+
return UrsulaSetupError(f"host_unsupported: {why}: {fix}", "host_unsupported", "fix", fix=fix)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def library() -> Library:
|
|
70
|
+
"""The extension to load: ``SQLITE_URSULA_VFS`` when set, else the one the wheel bundles.
|
|
71
|
+
|
|
72
|
+
Raises ``host_unsupported`` on a platform the extension does not support, and on an install
|
|
73
|
+
without a bundled library (the wheel of a platform with none).
|
|
74
|
+
"""
|
|
75
|
+
chosen = os.environ.get("SQLITE_URSULA_VFS", "")
|
|
76
|
+
if chosen:
|
|
77
|
+
return Library(chosen, "SQLITE_URSULA_VFS")
|
|
78
|
+
name = FILES.get(sys.platform)
|
|
79
|
+
if name is None:
|
|
80
|
+
raise unsupported(f"the extension has no VFS for {sys.platform}", UNDER_WSL)
|
|
81
|
+
bundled = Path(__file__).parent / "_lib" / name
|
|
82
|
+
if not bundled.is_file():
|
|
83
|
+
raise unsupported(
|
|
84
|
+
"this install of sqlite-ursula carries no extension for this platform", OWN_LIBRARY
|
|
85
|
+
)
|
|
86
|
+
return Library(str(bundled), "bundled")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def loads_extensions() -> bool:
|
|
90
|
+
"""Whether this Python's ``sqlite3`` was built to load extensions at all."""
|
|
91
|
+
return hasattr(sqlite3.Connection, "enable_load_extension")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load(conn: sqlite3.Connection, lib: Library) -> None:
|
|
95
|
+
"""Loads ``lib`` into ``conn``: the VFS once per process, the SQL functions on ``conn``.
|
|
96
|
+
|
|
97
|
+
Extension loading is on only for the load: the same switch enables SQL's ``load_extension()``.
|
|
98
|
+
The extension's refusal of this SQLite is ``host_unsupported``. A library SQLite cannot load,
|
|
99
|
+
or one without ``ursula_classify`` (older than this package), is ``local_setup``.
|
|
100
|
+
"""
|
|
101
|
+
if not loads_extensions():
|
|
102
|
+
raise unsupported(
|
|
103
|
+
"this Python's sqlite3 module was built without loadable extensions, as the "
|
|
104
|
+
"python.org macOS installer's, Apple's and pyenv's default builds are",
|
|
105
|
+
OTHER_PYTHON,
|
|
106
|
+
)
|
|
107
|
+
conn.enable_load_extension(True)
|
|
108
|
+
try:
|
|
109
|
+
conn.load_extension(lib.path)
|
|
110
|
+
except sqlite3.OperationalError as error:
|
|
111
|
+
if REFUSAL in str(error):
|
|
112
|
+
raise unsupported(str(error), OTHER_PYTHON) from error
|
|
113
|
+
raise UrsulaSetupError(
|
|
114
|
+
f"local_setup: SQLite could not load the sqlite-ursula extension at {lib.path} "
|
|
115
|
+
f"({error})",
|
|
116
|
+
"local_setup",
|
|
117
|
+
"fix",
|
|
118
|
+
cause=error,
|
|
119
|
+
) from error
|
|
120
|
+
finally:
|
|
121
|
+
conn.enable_load_extension(False)
|
|
122
|
+
try:
|
|
123
|
+
conn.execute("SELECT ursula_classify('', 0, NULL)").fetchone()
|
|
124
|
+
except sqlite3.OperationalError as error:
|
|
125
|
+
raise UrsulaSetupError(
|
|
126
|
+
f"local_setup: the sqlite-ursula extension at {lib.path} has no ursula_classify: it "
|
|
127
|
+
"is older than this package, so load the library of the package's own version",
|
|
128
|
+
"local_setup",
|
|
129
|
+
"fix",
|
|
130
|
+
cause=error,
|
|
131
|
+
) from error
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def connection(lib: Library) -> sqlite3.Connection:
|
|
135
|
+
"""A new in-memory connection with ``lib`` loaded, for the extension's SQL functions."""
|
|
136
|
+
conn = sqlite3.connect(":memory:", check_same_thread=False, isolation_level=None)
|
|
137
|
+
try:
|
|
138
|
+
load(conn, lib)
|
|
139
|
+
except BaseException:
|
|
140
|
+
conn.close()
|
|
141
|
+
raise
|
|
142
|
+
return conn
|
sqlite_ursula/_open.py
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""``open``, ``aopen``, ``import_sqlite`` and ``set_token``: the root module's calls of the
|
|
2
|
+
extension.
|
|
3
|
+
|
|
4
|
+
An open binds with ``ursula_open`` at timeout 0 and waits on the events thread while the bind
|
|
5
|
+
goes on, as Node's does: ``open`` on a ``threading.Event``, ``aopen`` on the event loop. The
|
|
6
|
+
extension shares one bind between concurrent opens of a stream, from any thread or task. Its own
|
|
7
|
+
deadline for the bind is far past ``timeout``, which governs the open: on the timeout, or on
|
|
8
|
+
Ctrl-C, a cancellation or any other exception once a reply named the request, the open cancels
|
|
9
|
+
the bind (``ursula_cancel``). A Ctrl-C inside the first ``ursula_open`` call, before its reply is
|
|
10
|
+
read, can miss the cancel.
|
|
11
|
+
|
|
12
|
+
``autocommit=False`` (Python 3.12 or newer) is refused, and so is setting it later. In that mode
|
|
13
|
+
the connection is always in a transaction: CPython runs its own ROLLBACK after a refused COMMIT,
|
|
14
|
+
which hides the refusal, and a connection kept open holds a read snapshot after any SELECT, which
|
|
15
|
+
stops checkpoints of the WAL.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sqlite3
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from . import _control
|
|
29
|
+
from . import _events
|
|
30
|
+
from ._args import flag
|
|
31
|
+
from ._args import invalid
|
|
32
|
+
from ._args import seconds
|
|
33
|
+
from ._connection import UrsulaConnection
|
|
34
|
+
from ._connection import autocommit_false
|
|
35
|
+
from ._connection import database_of
|
|
36
|
+
from .errors import UrsulaError
|
|
37
|
+
from .errors import UrsulaSetupError
|
|
38
|
+
from .errors import UrsulaUnavailableError
|
|
39
|
+
from .errors import error_of
|
|
40
|
+
|
|
41
|
+
#: The extension's own deadline for a bind: far past any ``timeout``, which governs the open.
|
|
42
|
+
EXTENSION_OPEN_MS = 24 * 60 * 60 * 1000
|
|
43
|
+
#: How long an open may take without ``timeout``.
|
|
44
|
+
DEFAULT_OPEN_S = 30.0
|
|
45
|
+
#: How long an import may take without ``timeout``.
|
|
46
|
+
DEFAULT_IMPORT_S = 600.0
|
|
47
|
+
#: ``sqlite3.connect``'s busy timeout unless the caller passes one: the connections of a binding
|
|
48
|
+
#: share its files.
|
|
49
|
+
DEFAULT_BUSY_S = 5.0
|
|
50
|
+
#: ``sqlite3.connect`` arguments ``open`` decides itself.
|
|
51
|
+
OWN = frozenset({"database", "uri"})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _url(url: object) -> str:
|
|
55
|
+
if not isinstance(url, str):
|
|
56
|
+
raise invalid(f"url must be a string, not {type(url).__name__}")
|
|
57
|
+
return url
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _token(token: object) -> str | None:
|
|
61
|
+
if token is not None and not isinstance(token, str):
|
|
62
|
+
raise invalid(
|
|
63
|
+
f"token must be a string, not {type(token).__name__}: for a token that changes, call "
|
|
64
|
+
"set_token() with each new one"
|
|
65
|
+
)
|
|
66
|
+
return token
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def set_token(token: str | None) -> None:
|
|
70
|
+
"""Sets the bearer token every request of the process carries from now on. ``None`` or ``""``
|
|
71
|
+
goes back to ``URSULA_VFS_TOKEN_FILE``, the file the extension reads the token from, when set.
|
|
72
|
+
A change resends the requests a 401 refused.
|
|
73
|
+
|
|
74
|
+
One token per process: the last call's, or the last ``open()``'s with a ``token``, applies.
|
|
75
|
+
"""
|
|
76
|
+
chosen = _token(token)
|
|
77
|
+
_control.call("SELECT ursula_set_token('', ?)", chosen or "")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def open( # noqa: A001 - the package's entry point, as Node's `open`
|
|
81
|
+
url: str,
|
|
82
|
+
*,
|
|
83
|
+
token: str | None = None,
|
|
84
|
+
dir: str | os.PathLike[str] | None = None, # noqa: A002 - the option's name, as Node's
|
|
85
|
+
read_only: bool = False,
|
|
86
|
+
recreate: bool = False,
|
|
87
|
+
max_pending_bytes: int | None = None,
|
|
88
|
+
timeout: float = DEFAULT_OPEN_S,
|
|
89
|
+
busy_timeout: float = DEFAULT_BUSY_S,
|
|
90
|
+
**connect_kwargs: Any,
|
|
91
|
+
) -> UrsulaConnection:
|
|
92
|
+
"""Opens the database the stream at ``url`` holds, and makes this process its owner.
|
|
93
|
+
|
|
94
|
+
The first open of a stream in the process binds it, catches the local cache up and claims the
|
|
95
|
+
stream, fencing every earlier owner. Later opens of it in the process return another
|
|
96
|
+
connection on the same binding, with no network. Concurrent opens, from any thread, share one
|
|
97
|
+
bind and its outcome.
|
|
98
|
+
|
|
99
|
+
- ``token``: the bearer token requests carry, applied with :func:`set_token` before the open.
|
|
100
|
+
- ``dir``: the cache root, created when missing. By default ``<user cache>/sqlite-ursula/0.8``.
|
|
101
|
+
- ``read_only``: claims nothing, so it never fences the owner, and writes fail with
|
|
102
|
+
``SQLITE_READONLY``. In a process that owns the stream it is a read-only connection on the
|
|
103
|
+
owner's binding. Otherwise the extension builds or catches up a reader cache of its own.
|
|
104
|
+
- ``recreate``: when the stream is missing and the local cache holds data, create the stream
|
|
105
|
+
again and move the cache aside, instead of failing with ``not_found``.
|
|
106
|
+
- ``max_pending_bytes``: how many bytes of compressed commits may wait for the stream before
|
|
107
|
+
writes fail with ``backlog_full``. By default 64 MiB.
|
|
108
|
+
- ``timeout``: how many seconds the open may take, 30 by default.
|
|
109
|
+
- ``busy_timeout``: ``sqlite3.connect``'s ``timeout``, 5 seconds by default, since the
|
|
110
|
+
connections of a binding share its files.
|
|
111
|
+
- Every other keyword goes to ``sqlite3.connect``. ``check_same_thread`` is ``False`` unless
|
|
112
|
+
given, since frameworks run statements on threads of their own. A ``factory`` must be a
|
|
113
|
+
subclass of :class:`UrsulaConnection`. ``autocommit=False`` is refused: in that mode the
|
|
114
|
+
connection is always in a transaction, so a refused COMMIT would be hidden by CPython's own
|
|
115
|
+
ROLLBACK, and a connection kept open would hold a read snapshot that stops checkpoints.
|
|
116
|
+
|
|
117
|
+
Returns an :class:`UrsulaConnection`, a ``sqlite3.Connection`` subclass. Statements are local:
|
|
118
|
+
commit, then call ``db.synced()`` before anything outside the process acts on what you wrote or
|
|
119
|
+
read.
|
|
120
|
+
|
|
121
|
+
Raises :class:`UrsulaSetupError` when it cannot succeed as asked (``host_unsupported`` when
|
|
122
|
+
this Python cannot load the extension), and :class:`UrsulaUnavailableError` when the timeout
|
|
123
|
+
passed first: the last condition the open met, or ``cancelled`` when it met none.
|
|
124
|
+
"""
|
|
125
|
+
plan = _plan(
|
|
126
|
+
url,
|
|
127
|
+
token,
|
|
128
|
+
dir,
|
|
129
|
+
read_only,
|
|
130
|
+
recreate,
|
|
131
|
+
max_pending_bytes,
|
|
132
|
+
timeout,
|
|
133
|
+
busy_timeout,
|
|
134
|
+
connect_kwargs,
|
|
135
|
+
)
|
|
136
|
+
bind = _Bind(plan)
|
|
137
|
+
while True:
|
|
138
|
+
waiter = threading.Event()
|
|
139
|
+
try:
|
|
140
|
+
binding = bind.ask(waiter)
|
|
141
|
+
if binding is not None:
|
|
142
|
+
return _connect(plan, binding)
|
|
143
|
+
left = bind.left()
|
|
144
|
+
woke = left > 0 and waiter.wait(left)
|
|
145
|
+
except BaseException as error:
|
|
146
|
+
if bind.again(error):
|
|
147
|
+
continue
|
|
148
|
+
raise
|
|
149
|
+
if not woke:
|
|
150
|
+
raise bind.timed_out()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def aopen(
|
|
154
|
+
url: str,
|
|
155
|
+
*,
|
|
156
|
+
token: str | None = None,
|
|
157
|
+
dir: str | os.PathLike[str] | None = None, # noqa: A002 - the option's name, as Node's
|
|
158
|
+
read_only: bool = False,
|
|
159
|
+
recreate: bool = False,
|
|
160
|
+
max_pending_bytes: int | None = None,
|
|
161
|
+
timeout: float = DEFAULT_OPEN_S,
|
|
162
|
+
busy_timeout: float = DEFAULT_BUSY_S,
|
|
163
|
+
**connect_kwargs: Any,
|
|
164
|
+
) -> UrsulaConnection:
|
|
165
|
+
""":func:`open` for asyncio: the same arguments, the same :class:`UrsulaConnection` and the same
|
|
166
|
+
errors, and it waits for the bind on the event loop instead of blocking it.
|
|
167
|
+
|
|
168
|
+
Cancelling it, as ``asyncio.timeout`` around it does, cancels the bind (``ursula_cancel``) and
|
|
169
|
+
raises ``CancelledError``, so no bind goes on to claim the stream after the caller gave up.
|
|
170
|
+
"""
|
|
171
|
+
plan = _plan(
|
|
172
|
+
url,
|
|
173
|
+
token,
|
|
174
|
+
dir,
|
|
175
|
+
read_only,
|
|
176
|
+
recreate,
|
|
177
|
+
max_pending_bytes,
|
|
178
|
+
timeout,
|
|
179
|
+
busy_timeout,
|
|
180
|
+
connect_kwargs,
|
|
181
|
+
)
|
|
182
|
+
bind = _Bind(plan)
|
|
183
|
+
while True:
|
|
184
|
+
waiter = _events.LoopEvent()
|
|
185
|
+
try:
|
|
186
|
+
binding = bind.ask(waiter)
|
|
187
|
+
if binding is not None:
|
|
188
|
+
return _connect(plan, binding)
|
|
189
|
+
left = bind.left()
|
|
190
|
+
woke = left > 0 and await waiter.wait(left)
|
|
191
|
+
except BaseException as error:
|
|
192
|
+
if bind.again(error):
|
|
193
|
+
continue
|
|
194
|
+
raise
|
|
195
|
+
if not woke:
|
|
196
|
+
raise bind.timed_out()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True)
|
|
200
|
+
class _Plan:
|
|
201
|
+
"""An open's checked arguments."""
|
|
202
|
+
|
|
203
|
+
stream: str
|
|
204
|
+
root: str | None
|
|
205
|
+
#: ``ursula_open``'s options.
|
|
206
|
+
options: str
|
|
207
|
+
timeout: float
|
|
208
|
+
read_only: bool
|
|
209
|
+
busy: float
|
|
210
|
+
factory: type[UrsulaConnection]
|
|
211
|
+
connect_kwargs: dict[str, Any]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _plan(
|
|
215
|
+
url: object,
|
|
216
|
+
token: object,
|
|
217
|
+
dir: str | os.PathLike[str] | None, # noqa: A002 - the option's name
|
|
218
|
+
read_only: object,
|
|
219
|
+
recreate: object,
|
|
220
|
+
max_pending_bytes: object,
|
|
221
|
+
timeout: object,
|
|
222
|
+
busy_timeout: object,
|
|
223
|
+
connect_kwargs: dict[str, Any],
|
|
224
|
+
) -> _Plan:
|
|
225
|
+
"""Checks an open's arguments, starts the extension and applies the token."""
|
|
226
|
+
stream = _url(url)
|
|
227
|
+
chosen = _token(token)
|
|
228
|
+
settings: dict[str, object] = {"timeout_ms": EXTENSION_OPEN_MS}
|
|
229
|
+
if max_pending_bytes is not None:
|
|
230
|
+
if (
|
|
231
|
+
isinstance(max_pending_bytes, bool)
|
|
232
|
+
or not isinstance(max_pending_bytes, int)
|
|
233
|
+
or max_pending_bytes < 0
|
|
234
|
+
):
|
|
235
|
+
raise invalid(
|
|
236
|
+
f"max_pending_bytes must be a whole number of bytes, not {max_pending_bytes!r}"
|
|
237
|
+
)
|
|
238
|
+
settings["max_pending_bytes"] = max_pending_bytes
|
|
239
|
+
if flag("recreate", recreate):
|
|
240
|
+
settings["recreate"] = True
|
|
241
|
+
ro = flag("read_only", read_only)
|
|
242
|
+
if ro:
|
|
243
|
+
# With `recreate` too, the extension refuses it: `invalid_argument`.
|
|
244
|
+
settings["read_only"] = True
|
|
245
|
+
limit = seconds("timeout", timeout, positive=True)
|
|
246
|
+
busy = seconds("busy_timeout", busy_timeout, positive=False)
|
|
247
|
+
for key in OWN.intersection(connect_kwargs):
|
|
248
|
+
raise invalid(f"open() decides sqlite3.connect's {key}")
|
|
249
|
+
if connect_kwargs.get("autocommit") is False:
|
|
250
|
+
raise autocommit_false()
|
|
251
|
+
factory = connect_kwargs.pop("factory", UrsulaConnection)
|
|
252
|
+
if not (isinstance(factory, type) and issubclass(factory, UrsulaConnection)):
|
|
253
|
+
raise invalid(f"factory must be a subclass of UrsulaConnection, not {factory!r}")
|
|
254
|
+
root = None if dir is None else os.fspath(dir)
|
|
255
|
+
_control.start()
|
|
256
|
+
# The token is applied once, here, never in `ursula_open`'s options, which a bind sends
|
|
257
|
+
# again on each of its retries, over a token applied meanwhile.
|
|
258
|
+
if chosen is not None:
|
|
259
|
+
set_token(chosen)
|
|
260
|
+
return _Plan(stream, root, json.dumps(settings), limit, ro, busy, factory, connect_kwargs)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class _Bind:
|
|
264
|
+
"""One open's bind of its stream: ``ursula_open`` with timeout 0, asked again each time its
|
|
265
|
+
waiter wakes, until it is done. ``open`` waits on a ``threading.Event`` and ``aopen`` on a
|
|
266
|
+
:class:`_events.LoopEvent`, through the same steps.
|
|
267
|
+
|
|
268
|
+
Any exception once a reply named the request cancels it, in a later pass too (one a gap or a
|
|
269
|
+
lost events thread woke), so no bind goes on to claim the stream after the open gave up. A
|
|
270
|
+
Ctrl-C that lands inside the first ``ursula_open`` call, before its reply is read, can miss the
|
|
271
|
+
cancel: that bind then runs to the extension's own deadline.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
def __init__(self, plan: _Plan) -> None:
|
|
275
|
+
self._plan = plan
|
|
276
|
+
self._deadline = time.monotonic() + plan.timeout
|
|
277
|
+
# The request of the last reply that was not done: concurrent opens of a stream, and each
|
|
278
|
+
# pass of this one, share it until it resolves.
|
|
279
|
+
self._known: int | None = None
|
|
280
|
+
self._ev: _events.Events | None = None
|
|
281
|
+
self._waiter: _events.Waiter | None = None
|
|
282
|
+
|
|
283
|
+
def ask(self, waiter: _events.Waiter) -> dict[str, Any] | None:
|
|
284
|
+
"""One pass: the binding when the bind is done, else ``None`` with ``waiter`` registered
|
|
285
|
+
for the request's completion."""
|
|
286
|
+
plan = self._plan
|
|
287
|
+
self._ev = None
|
|
288
|
+
self._waiter = None
|
|
289
|
+
with _control.lock():
|
|
290
|
+
# The thread first, so its cursor precedes the request, and the waiter registered in
|
|
291
|
+
# the step that got the request, before any event about it applies.
|
|
292
|
+
self._ev = _events.events()
|
|
293
|
+
opened = _control.call(
|
|
294
|
+
"SELECT ursula_open(?, ?, ?, 0)", plan.stream, plan.root, plan.options
|
|
295
|
+
)
|
|
296
|
+
if opened["done"]:
|
|
297
|
+
return opened
|
|
298
|
+
self._known = int(opened["request"])
|
|
299
|
+
self._ev.wait_open(self._known, waiter)
|
|
300
|
+
self._waiter = waiter
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
def left(self) -> float:
|
|
304
|
+
return self._deadline - time.monotonic()
|
|
305
|
+
|
|
306
|
+
def again(self, error: BaseException) -> bool:
|
|
307
|
+
"""A pass raised ``error``: whether to ask anew, when another open's bind of the stream,
|
|
308
|
+
which this one shared, was cancelled. Otherwise the request is cancelled and the caller
|
|
309
|
+
raises ``error``."""
|
|
310
|
+
if (
|
|
311
|
+
isinstance(error, UrsulaUnavailableError)
|
|
312
|
+
and error.kind == "cancelled"
|
|
313
|
+
and time.monotonic() < self._deadline
|
|
314
|
+
):
|
|
315
|
+
self._known = None
|
|
316
|
+
return True
|
|
317
|
+
if self._known is not None:
|
|
318
|
+
self._forget()
|
|
319
|
+
# One that resolved meanwhile is no longer a request: the cancel finds nothing.
|
|
320
|
+
_give_up(self._plan.stream, self._known, self._plan.timeout)
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
def timed_out(self) -> UrsulaError:
|
|
324
|
+
"""The timeout passed: the request is cancelled, and the error is the last condition it
|
|
325
|
+
met, or ``cancelled``."""
|
|
326
|
+
assert self._known is not None
|
|
327
|
+
self._forget()
|
|
328
|
+
return _give_up(self._plan.stream, self._known, self._plan.timeout)
|
|
329
|
+
|
|
330
|
+
def _forget(self) -> None:
|
|
331
|
+
if self._ev is not None and self._waiter is not None and self._known is not None:
|
|
332
|
+
self._ev.forget_open(self._known, self._waiter)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _give_up(stream: str, request: int, timeout: float) -> UrsulaError:
|
|
336
|
+
"""The open gave up: cancels ``request``, and the error is the last condition it met, or
|
|
337
|
+
``cancelled``. The cancel waits up to a second for a disk job of the open, so it runs on a
|
|
338
|
+
connection of its own."""
|
|
339
|
+
try:
|
|
340
|
+
ended = _control.call_waiting("SELECT ursula_cancel(?)", request)
|
|
341
|
+
except UrsulaError:
|
|
342
|
+
# It resolved meanwhile and its result was taken.
|
|
343
|
+
ended = None
|
|
344
|
+
if ended is None or ended.get("kind") == "cancelled":
|
|
345
|
+
return UrsulaUnavailableError(
|
|
346
|
+
f"cancelled: the open of {stream} gave up after {timeout:g} s before it met any "
|
|
347
|
+
"condition",
|
|
348
|
+
"cancelled",
|
|
349
|
+
"retry",
|
|
350
|
+
)
|
|
351
|
+
return error_of(
|
|
352
|
+
ended, f"{ended['message']}: the open of {stream} gave up after {timeout:g} s", None
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _connect(plan: _Plan, binding: dict[str, Any]) -> UrsulaConnection:
|
|
357
|
+
"""A connection to what ``ursula_open`` bound."""
|
|
358
|
+
path = str(binding["path"])
|
|
359
|
+
kwargs = dict(plan.connect_kwargs)
|
|
360
|
+
kwargs.setdefault("check_same_thread", False)
|
|
361
|
+
try:
|
|
362
|
+
conn = sqlite3.connect(
|
|
363
|
+
database_of(path, plan.read_only),
|
|
364
|
+
timeout=plan.busy,
|
|
365
|
+
factory=plan.factory,
|
|
366
|
+
uri=plan.read_only,
|
|
367
|
+
**kwargs,
|
|
368
|
+
)
|
|
369
|
+
except (sqlite3.Error, TypeError, ValueError) as error:
|
|
370
|
+
raise UrsulaSetupError(
|
|
371
|
+
f"invalid_argument: bound {binding['name']}, then sqlite3 refused to open {path} "
|
|
372
|
+
f"({error})",
|
|
373
|
+
"invalid_argument",
|
|
374
|
+
"fix",
|
|
375
|
+
cause=error,
|
|
376
|
+
) from error
|
|
377
|
+
conn._bind(str(binding["name"]), path, plan.read_only, plan.busy)
|
|
378
|
+
return conn
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@dataclass(frozen=True)
|
|
382
|
+
class Imported:
|
|
383
|
+
"""What :func:`import_sqlite` published: the stream's first snapshot."""
|
|
384
|
+
|
|
385
|
+
#: The snapshot's stream offset, the end of the import's claim.
|
|
386
|
+
offset: int
|
|
387
|
+
digest: str
|
|
388
|
+
pages: int
|
|
389
|
+
#: The compressed snapshot's size.
|
|
390
|
+
bytes: int
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def import_sqlite(
|
|
394
|
+
url: str,
|
|
395
|
+
file: str | os.PathLike[str],
|
|
396
|
+
*,
|
|
397
|
+
token: str | None = None,
|
|
398
|
+
timeout: float = DEFAULT_IMPORT_S,
|
|
399
|
+
) -> Imported:
|
|
400
|
+
"""Imports the plain SQLite database ``file`` into a new stream at ``url``, as the stream's
|
|
401
|
+
first snapshot, page for page. Then :func:`open` the stream as any other.
|
|
402
|
+
|
|
403
|
+
The file must have 4096-byte pages (``PRAGMA page_size = 4096; VACUUM INTO ...`` makes such a
|
|
404
|
+
copy), no ``-wal`` beside it and no hot journal. It is only read. The stream must be missing or
|
|
405
|
+
empty: anything else fails with ``stream_state`` and nothing is published. ``token`` is the
|
|
406
|
+
bearer token of the import's requests alone.
|
|
407
|
+
|
|
408
|
+
It blocks the calling thread until it is done, for at most ``timeout`` seconds (10 minutes by
|
|
409
|
+
default). Errors are :class:`UrsulaSetupError` (such as ``invalid_argument`` for the file) and
|
|
410
|
+
:class:`UrsulaUnavailableError`, whose action is ``fix``: the stream may hold the import's
|
|
411
|
+
claim.
|
|
412
|
+
"""
|
|
413
|
+
stream = _url(url)
|
|
414
|
+
chosen = _token(token)
|
|
415
|
+
limit = seconds("timeout", timeout, positive=True)
|
|
416
|
+
source = os.fspath(file)
|
|
417
|
+
settings = {} if chosen is None else {"token": chosen}
|
|
418
|
+
_control.start()
|
|
419
|
+
# On a connection of its own: the import blocks for as long as it takes.
|
|
420
|
+
done = _control.call_waiting(
|
|
421
|
+
"SELECT ursula_import(?, ?, ?, ?)",
|
|
422
|
+
stream,
|
|
423
|
+
source,
|
|
424
|
+
json.dumps(settings),
|
|
425
|
+
max(1, round(limit * 1000)),
|
|
426
|
+
)
|
|
427
|
+
return Imported(
|
|
428
|
+
offset=int(done["offset"]),
|
|
429
|
+
digest=str(done["digest"]),
|
|
430
|
+
pages=int(done["pages"]),
|
|
431
|
+
bytes=int(done["bytes"]),
|
|
432
|
+
)
|