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/_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
+ )