pyxpo-sqlite 0.1.0a1__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.
- pyxpo_sqlite-0.1.0a1/LICENSE +21 -0
- pyxpo_sqlite-0.1.0a1/PKG-INFO +120 -0
- pyxpo_sqlite-0.1.0a1/README.md +101 -0
- pyxpo_sqlite-0.1.0a1/pyproject.toml +42 -0
- pyxpo_sqlite-0.1.0a1/pyxpo_sqlite/__init__.py +534 -0
- pyxpo_sqlite-0.1.0a1/pyxpo_sqlite/py.typed +0 -0
- pyxpo_sqlite-0.1.0a1/pyxpo_sqlite/pyxpo-module.json +6 -0
- pyxpo_sqlite-0.1.0a1/pyxpo_sqlite.egg-info/PKG-INFO +120 -0
- pyxpo_sqlite-0.1.0a1/pyxpo_sqlite.egg-info/SOURCES.txt +12 -0
- pyxpo_sqlite-0.1.0a1/pyxpo_sqlite.egg-info/dependency_links.txt +1 -0
- pyxpo_sqlite-0.1.0a1/pyxpo_sqlite.egg-info/entry_points.txt +2 -0
- pyxpo_sqlite-0.1.0a1/pyxpo_sqlite.egg-info/requires.txt +1 -0
- pyxpo_sqlite-0.1.0a1/pyxpo_sqlite.egg-info/top_level.txt +1 -0
- pyxpo_sqlite-0.1.0a1/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vishal Chaubey
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyxpo-sqlite
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: SQLite databases for pyxpo apps (expo-sqlite API)
|
|
5
|
+
Author: Vishal Chaubey
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/pyxpo/pyxpo/tree/main/packages/sqlite
|
|
8
|
+
Project-URL: Issues, https://github.com/pyxpo/pyxpo/issues
|
|
9
|
+
Keywords: pyxpo,pyxpo-module,mobile,android,ios
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: User Interfaces
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: pyxpo<0.2,>=0.1.0a2
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# pyxpo-sqlite
|
|
21
|
+
|
|
22
|
+
SQLite databases that persist across app restarts, for [pyxpo](https://github.com/pyxpo/pyxpo) apps. The API
|
|
23
|
+
follows [expo-sqlite](https://docs.expo.dev/versions/latest/sdk/sqlite/). The phone's Python ships the `sqlite3`
|
|
24
|
+
module, so this package is pure Python: no native code and no rebuild of the native shell.
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pip install pyxpo-sqlite
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```toml
|
|
31
|
+
[tool.pyxpo]
|
|
32
|
+
modules = ["sqlite"]
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import pyxpo_sqlite as SQLite
|
|
37
|
+
|
|
38
|
+
db = await SQLite.open_database_async("app.db")
|
|
39
|
+
await db.exec_async("""
|
|
40
|
+
PRAGMA journal_mode = WAL;
|
|
41
|
+
CREATE TABLE IF NOT EXISTS todo (id INTEGER PRIMARY KEY, title TEXT NOT NULL, done INTEGER DEFAULT 0);
|
|
42
|
+
""")
|
|
43
|
+
res = await db.run_async("INSERT INTO todo (title) VALUES (?)", "Buy milk")
|
|
44
|
+
print(res.last_insert_row_id, res.changes)
|
|
45
|
+
|
|
46
|
+
row = await db.get_first_async("SELECT * FROM todo WHERE id = ?", res.last_insert_row_id) # dict or None
|
|
47
|
+
rows = await db.get_all_async("SELECT * FROM todo WHERE done = $done", {"$done": 0}) # list of dicts
|
|
48
|
+
async for row in db.get_each_async("SELECT * FROM todo"): # one at a time
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
async def mark_all():
|
|
52
|
+
await db.run_async("UPDATE todo SET done = 1")
|
|
53
|
+
await db.with_transaction_async(mark_all) # COMMIT, or ROLLBACK if mark_all raises
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Functions
|
|
57
|
+
|
|
58
|
+
| Function | Notes |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| `open_database_async(name, options=None, directory=None)` | Opens or creates `name` in `directory` (default `default_database_directory`). `":memory:"` opens a private in-memory database. Opening the same file again returns the same connection unless `SQLiteOpenOptions(use_new_connection=True)`. |
|
|
61
|
+
| `delete_database_async(name, directory=None)` | Deletes the file and its `-wal`, `-shm` and `-journal` files. Raises while the database is open. |
|
|
62
|
+
| `deserialize_database_async(data)` | An in-memory database holding `data` from `serialize_async()`. |
|
|
63
|
+
| `default_database_directory` | `<documents>/SQLite`: the app's Documents folder on iOS, `filesDir` on Android. |
|
|
64
|
+
|
|
65
|
+
## `SQLiteDatabase`
|
|
66
|
+
|
|
67
|
+
| Method | Notes |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| `exec_async(sql)` | Runs `;`-separated statements without values or results (schema, PRAGMA). |
|
|
70
|
+
| `run_async(sql, *values)` | Returns `SQLiteRunResult(last_insert_row_id, changes)`. |
|
|
71
|
+
| `get_first_async(sql, *values)` | First row as a dict, or `None`. |
|
|
72
|
+
| `get_all_async(sql, *values)` | All rows as dicts. |
|
|
73
|
+
| `get_each_async(sql, *values)` | Async iterator of rows, read in small batches. |
|
|
74
|
+
| `prepare_async(sql)` | `SQLiteStatement`: `execute_async(*values)` returns a result with `last_insert_row_id`, `changes`, `get_first_async()`, `get_all_async()` and async iteration; also `get_column_names_async()` and `finalize_async()`. |
|
|
75
|
+
| `with_transaction_async(task)` | Runs `await task()` inside `BEGIN`/`COMMIT`, rolling back if it raises. Queries from other tasks on the same connection meanwhile join the transaction. |
|
|
76
|
+
| `with_exclusive_transaction_async(task)` | Runs `await task(txn)` on a separate connection inside `BEGIN EXCLUSIVE`; run queries on `txn` only. Not for `":memory:"`. |
|
|
77
|
+
| `is_in_transaction_async()` | |
|
|
78
|
+
| `serialize_async(name="main")` | The database as bytes. |
|
|
79
|
+
| `close_async()` | A shared connection closes when every opener closed it. |
|
|
80
|
+
| `database_path`, `database_name` | |
|
|
81
|
+
|
|
82
|
+
Values: positional (`run_async(sql, 1, "a")`), one list or tuple, or one dict for named parameters. Dict keys may
|
|
83
|
+
keep the prefix used in the SQL (`{"$id": 1}` for `$id`, `:id`, `@id`) or drop it. Rows map column names to
|
|
84
|
+
Python values: `int`, `float`, `str`, `bytes` or `None`.
|
|
85
|
+
|
|
86
|
+
Every method also has a `_sync` form that blocks (for example `get_all_sync`), and camelCase names work
|
|
87
|
+
(`getAllAsync`, `runAsync`, `lastInsertRowId`). Async methods run on one worker thread per connection, so the UI
|
|
88
|
+
stays responsive. SQL errors raise the `sqlite3` exceptions (`sqlite3.OperationalError`,
|
|
89
|
+
`sqlite3.IntegrityError`, ...).
|
|
90
|
+
|
|
91
|
+
## Provider and hook
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from pyxpo_sqlite import SQLiteProvider, use_sqlite_context
|
|
95
|
+
|
|
96
|
+
async def migrate(db):
|
|
97
|
+
await db.exec_async("CREATE TABLE IF NOT EXISTS todo (id INTEGER PRIMARY KEY, title TEXT)")
|
|
98
|
+
|
|
99
|
+
def App():
|
|
100
|
+
return SQLiteProvider(TodoList(), database_name="app.db", on_init=migrate, fallback=Text("Loading..."))
|
|
101
|
+
|
|
102
|
+
@component
|
|
103
|
+
def TodoList():
|
|
104
|
+
db = use_sqlite_context()
|
|
105
|
+
...
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`SQLiteProvider` props: `database_name`, `options`, `directory`, `on_init(db)` (awaited before the children render),
|
|
109
|
+
`on_error(error)` (without it the error is raised to the nearest `ErrorBoundary`) and `fallback`.
|
|
110
|
+
|
|
111
|
+
## Not supported
|
|
112
|
+
|
|
113
|
+
Change listeners (`addDatabaseChangeListener`), `enableChangeListener`, SQLCipher encryption, loading extensions
|
|
114
|
+
and the key-value `Storage` export of expo-sqlite (use `pyxpo-storage`).
|
|
115
|
+
|
|
116
|
+
## Platforms
|
|
117
|
+
|
|
118
|
+
iOS, Android and desktop. The bundled Python on iOS and Android is 3.13. On desktop, `serialize` and
|
|
119
|
+
`deserialize` need Python 3.11 or later and raise `sqlite3.NotSupportedError` before that. On desktop, databases
|
|
120
|
+
live in `.pyxpo-data/documents/SQLite` under the working directory.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# pyxpo-sqlite
|
|
2
|
+
|
|
3
|
+
SQLite databases that persist across app restarts, for [pyxpo](https://github.com/pyxpo/pyxpo) apps. The API
|
|
4
|
+
follows [expo-sqlite](https://docs.expo.dev/versions/latest/sdk/sqlite/). The phone's Python ships the `sqlite3`
|
|
5
|
+
module, so this package is pure Python: no native code and no rebuild of the native shell.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install pyxpo-sqlite
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```toml
|
|
12
|
+
[tool.pyxpo]
|
|
13
|
+
modules = ["sqlite"]
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import pyxpo_sqlite as SQLite
|
|
18
|
+
|
|
19
|
+
db = await SQLite.open_database_async("app.db")
|
|
20
|
+
await db.exec_async("""
|
|
21
|
+
PRAGMA journal_mode = WAL;
|
|
22
|
+
CREATE TABLE IF NOT EXISTS todo (id INTEGER PRIMARY KEY, title TEXT NOT NULL, done INTEGER DEFAULT 0);
|
|
23
|
+
""")
|
|
24
|
+
res = await db.run_async("INSERT INTO todo (title) VALUES (?)", "Buy milk")
|
|
25
|
+
print(res.last_insert_row_id, res.changes)
|
|
26
|
+
|
|
27
|
+
row = await db.get_first_async("SELECT * FROM todo WHERE id = ?", res.last_insert_row_id) # dict or None
|
|
28
|
+
rows = await db.get_all_async("SELECT * FROM todo WHERE done = $done", {"$done": 0}) # list of dicts
|
|
29
|
+
async for row in db.get_each_async("SELECT * FROM todo"): # one at a time
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
async def mark_all():
|
|
33
|
+
await db.run_async("UPDATE todo SET done = 1")
|
|
34
|
+
await db.with_transaction_async(mark_all) # COMMIT, or ROLLBACK if mark_all raises
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Functions
|
|
38
|
+
|
|
39
|
+
| Function | Notes |
|
|
40
|
+
| --- | --- |
|
|
41
|
+
| `open_database_async(name, options=None, directory=None)` | Opens or creates `name` in `directory` (default `default_database_directory`). `":memory:"` opens a private in-memory database. Opening the same file again returns the same connection unless `SQLiteOpenOptions(use_new_connection=True)`. |
|
|
42
|
+
| `delete_database_async(name, directory=None)` | Deletes the file and its `-wal`, `-shm` and `-journal` files. Raises while the database is open. |
|
|
43
|
+
| `deserialize_database_async(data)` | An in-memory database holding `data` from `serialize_async()`. |
|
|
44
|
+
| `default_database_directory` | `<documents>/SQLite`: the app's Documents folder on iOS, `filesDir` on Android. |
|
|
45
|
+
|
|
46
|
+
## `SQLiteDatabase`
|
|
47
|
+
|
|
48
|
+
| Method | Notes |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| `exec_async(sql)` | Runs `;`-separated statements without values or results (schema, PRAGMA). |
|
|
51
|
+
| `run_async(sql, *values)` | Returns `SQLiteRunResult(last_insert_row_id, changes)`. |
|
|
52
|
+
| `get_first_async(sql, *values)` | First row as a dict, or `None`. |
|
|
53
|
+
| `get_all_async(sql, *values)` | All rows as dicts. |
|
|
54
|
+
| `get_each_async(sql, *values)` | Async iterator of rows, read in small batches. |
|
|
55
|
+
| `prepare_async(sql)` | `SQLiteStatement`: `execute_async(*values)` returns a result with `last_insert_row_id`, `changes`, `get_first_async()`, `get_all_async()` and async iteration; also `get_column_names_async()` and `finalize_async()`. |
|
|
56
|
+
| `with_transaction_async(task)` | Runs `await task()` inside `BEGIN`/`COMMIT`, rolling back if it raises. Queries from other tasks on the same connection meanwhile join the transaction. |
|
|
57
|
+
| `with_exclusive_transaction_async(task)` | Runs `await task(txn)` on a separate connection inside `BEGIN EXCLUSIVE`; run queries on `txn` only. Not for `":memory:"`. |
|
|
58
|
+
| `is_in_transaction_async()` | |
|
|
59
|
+
| `serialize_async(name="main")` | The database as bytes. |
|
|
60
|
+
| `close_async()` | A shared connection closes when every opener closed it. |
|
|
61
|
+
| `database_path`, `database_name` | |
|
|
62
|
+
|
|
63
|
+
Values: positional (`run_async(sql, 1, "a")`), one list or tuple, or one dict for named parameters. Dict keys may
|
|
64
|
+
keep the prefix used in the SQL (`{"$id": 1}` for `$id`, `:id`, `@id`) or drop it. Rows map column names to
|
|
65
|
+
Python values: `int`, `float`, `str`, `bytes` or `None`.
|
|
66
|
+
|
|
67
|
+
Every method also has a `_sync` form that blocks (for example `get_all_sync`), and camelCase names work
|
|
68
|
+
(`getAllAsync`, `runAsync`, `lastInsertRowId`). Async methods run on one worker thread per connection, so the UI
|
|
69
|
+
stays responsive. SQL errors raise the `sqlite3` exceptions (`sqlite3.OperationalError`,
|
|
70
|
+
`sqlite3.IntegrityError`, ...).
|
|
71
|
+
|
|
72
|
+
## Provider and hook
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from pyxpo_sqlite import SQLiteProvider, use_sqlite_context
|
|
76
|
+
|
|
77
|
+
async def migrate(db):
|
|
78
|
+
await db.exec_async("CREATE TABLE IF NOT EXISTS todo (id INTEGER PRIMARY KEY, title TEXT)")
|
|
79
|
+
|
|
80
|
+
def App():
|
|
81
|
+
return SQLiteProvider(TodoList(), database_name="app.db", on_init=migrate, fallback=Text("Loading..."))
|
|
82
|
+
|
|
83
|
+
@component
|
|
84
|
+
def TodoList():
|
|
85
|
+
db = use_sqlite_context()
|
|
86
|
+
...
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`SQLiteProvider` props: `database_name`, `options`, `directory`, `on_init(db)` (awaited before the children render),
|
|
90
|
+
`on_error(error)` (without it the error is raised to the nearest `ErrorBoundary`) and `fallback`.
|
|
91
|
+
|
|
92
|
+
## Not supported
|
|
93
|
+
|
|
94
|
+
Change listeners (`addDatabaseChangeListener`), `enableChangeListener`, SQLCipher encryption, loading extensions
|
|
95
|
+
and the key-value `Storage` export of expo-sqlite (use `pyxpo-storage`).
|
|
96
|
+
|
|
97
|
+
## Platforms
|
|
98
|
+
|
|
99
|
+
iOS, Android and desktop. The bundled Python on iOS and Android is 3.13. On desktop, `serialize` and
|
|
100
|
+
`deserialize` need Python 3.11 or later and raise `sqlite3.NotSupportedError` before that. On desktop, databases
|
|
101
|
+
live in `.pyxpo-data/documents/SQLite` under the working directory.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyxpo-sqlite"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "SQLite databases for pyxpo apps (expo-sqlite API)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Vishal Chaubey" }]
|
|
13
|
+
keywords = ["pyxpo", "pyxpo-module", "mobile", "android", "ios"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Software Development :: User Interfaces",
|
|
19
|
+
]
|
|
20
|
+
requires-python = ">=3.9"
|
|
21
|
+
dependencies = ["pyxpo>=0.1.0a2,<0.2"]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/pyxpo/pyxpo/tree/main/packages/sqlite"
|
|
25
|
+
Issues = "https://github.com/pyxpo/pyxpo/issues"
|
|
26
|
+
|
|
27
|
+
# How `pyxpo build` finds this module: the entry point name is the module name apps list in
|
|
28
|
+
# `[tool.pyxpo] modules`, its value the import package holding pyxpo-module.json.
|
|
29
|
+
[project.entry-points."pyxpo.modules"]
|
|
30
|
+
sqlite = "pyxpo_sqlite"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools]
|
|
33
|
+
packages = ["pyxpo_sqlite"]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.dynamic]
|
|
36
|
+
version = { attr = "pyxpo_sqlite.__version__" }
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.package-data]
|
|
39
|
+
pyxpo_sqlite = ["pyxpo-module.json", "py.typed", "ios/**/*", "android/**/*"]
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.exclude-package-data]
|
|
42
|
+
"*" = ["__pycache__", "*.pyc", ".DS_Store"]
|
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
"""SQLite databases that persist across app restarts: the expo-sqlite API.
|
|
2
|
+
|
|
3
|
+
import pyxpo_sqlite as SQLite
|
|
4
|
+
|
|
5
|
+
db = await SQLite.open_database_async("app.db")
|
|
6
|
+
await db.exec_async('''
|
|
7
|
+
PRAGMA journal_mode = WAL;
|
|
8
|
+
CREATE TABLE IF NOT EXISTS todo (id INTEGER PRIMARY KEY, title TEXT NOT NULL, done INTEGER DEFAULT 0);
|
|
9
|
+
''')
|
|
10
|
+
res = await db.run_async("INSERT INTO todo (title) VALUES (?)", "Buy milk")
|
|
11
|
+
res.last_insert_row_id, res.changes
|
|
12
|
+
row = await db.get_first_async("SELECT * FROM todo WHERE id = ?", res.last_insert_row_id) # dict or None
|
|
13
|
+
rows = await db.get_all_async("SELECT * FROM todo WHERE done = $done", {"$done": 0})
|
|
14
|
+
async for row in db.get_each_async("SELECT * FROM todo"):
|
|
15
|
+
...
|
|
16
|
+
async def move():
|
|
17
|
+
await db.run_async("UPDATE todo SET done = 1 WHERE id = ?", 1)
|
|
18
|
+
await db.with_transaction_async(move) # rolls back if move raises
|
|
19
|
+
|
|
20
|
+
Databases live in `<documents>/SQLite/` (`default_database_directory`). The phone's Python ships `sqlite3`, so
|
|
21
|
+
this package needs no native code; async methods run on one worker thread per connection.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import sqlite3
|
|
29
|
+
import sys
|
|
30
|
+
import threading
|
|
31
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from typing import Any, AsyncIterator, Awaitable, Callable, Dict, Iterator, List, Optional, Sequence, Tuple
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0a1"
|
|
36
|
+
|
|
37
|
+
Row = Dict[str, Any]
|
|
38
|
+
_BATCH = 64
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _documents() -> str:
|
|
42
|
+
home = os.environ.get("HOME") or os.path.expanduser("~")
|
|
43
|
+
if sys.platform == "ios":
|
|
44
|
+
return os.path.join(home, "Documents")
|
|
45
|
+
if sys.platform == "android":
|
|
46
|
+
return home # the app's filesDir
|
|
47
|
+
return os.path.join(os.getcwd(), ".pyxpo-data", "documents")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _default_dir() -> str:
|
|
51
|
+
return os.path.join(_documents(), "SQLite")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def __getattr__(name: str) -> Any:
|
|
55
|
+
if name in ("default_database_directory", "defaultDatabaseDirectory"):
|
|
56
|
+
return _default_dir()
|
|
57
|
+
raise AttributeError(name)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class SQLiteRunResult:
|
|
62
|
+
last_insert_row_id: int
|
|
63
|
+
changes: int
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def lastInsertRowId(self) -> int: # noqa: N802
|
|
67
|
+
return self.last_insert_row_id
|
|
68
|
+
|
|
69
|
+
def __getitem__(self, key: str) -> int:
|
|
70
|
+
return {"lastInsertRowId": self.last_insert_row_id, "changes": self.changes}[key]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class SQLiteOpenOptions:
|
|
75
|
+
"""`use_new_connection`: open a separate connection instead of sharing the open one for this file."""
|
|
76
|
+
use_new_connection: bool = False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _params(args: Sequence[Any]) -> Any:
|
|
80
|
+
"""Positional values, one list/tuple, or one dict of named values (`$name`, `:name`, `@name` or `name`)."""
|
|
81
|
+
if len(args) == 1 and isinstance(args[0], dict):
|
|
82
|
+
return {(k[1:] if k[:1] in "$:@" else k): v for k, v in args[0].items()}
|
|
83
|
+
if len(args) == 1 and isinstance(args[0], (list, tuple)):
|
|
84
|
+
return tuple(args[0])
|
|
85
|
+
return tuple(args)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _script(c: sqlite3.Connection, source: str) -> None:
|
|
89
|
+
"""Runs `;`-separated statements one by one. `executescript` would COMMIT an open transaction first."""
|
|
90
|
+
buf = ""
|
|
91
|
+
for part in source.split(";"):
|
|
92
|
+
buf += part + ";"
|
|
93
|
+
if sqlite3.complete_statement(buf):
|
|
94
|
+
if buf.strip(" \t\r\n;"):
|
|
95
|
+
c.execute(buf).fetchall()
|
|
96
|
+
buf = ""
|
|
97
|
+
if buf.strip(" \t\r\n;"):
|
|
98
|
+
c.execute(buf.rstrip(";")).fetchall()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _need_311(what: str) -> None:
|
|
102
|
+
if not hasattr(sqlite3.Connection, "serialize"):
|
|
103
|
+
raise sqlite3.NotSupportedError("%s needs Python 3.11 or later" % what)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _rows(cur: sqlite3.Cursor, raw: List[Tuple[Any, ...]]) -> List[Row]:
|
|
107
|
+
names = [d[0] for d in cur.description or ()]
|
|
108
|
+
return [dict(zip(names, r)) for r in raw]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class SQLiteDatabase:
|
|
112
|
+
"""An open database. Every method has an `_async` form (worker thread) and a `_sync` form (blocks)."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, database_name: str, path: str, options: SQLiteOpenOptions) -> None:
|
|
115
|
+
self.database_name = database_name
|
|
116
|
+
self.database_path = path
|
|
117
|
+
self.options = options
|
|
118
|
+
self._conn: Optional[sqlite3.Connection] = sqlite3.connect(
|
|
119
|
+
path, check_same_thread=False, isolation_level=None, timeout=5.0)
|
|
120
|
+
self._lock = threading.RLock()
|
|
121
|
+
self._pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="pyxpo-sqlite")
|
|
122
|
+
self._refs = 1
|
|
123
|
+
|
|
124
|
+
# plumbing
|
|
125
|
+
def _c(self) -> sqlite3.Connection:
|
|
126
|
+
if self._conn is None:
|
|
127
|
+
raise sqlite3.ProgrammingError("the database %r is closed" % self.database_name)
|
|
128
|
+
return self._conn
|
|
129
|
+
|
|
130
|
+
def _do(self, fn: Callable[[sqlite3.Connection], Any]) -> Any:
|
|
131
|
+
with self._lock:
|
|
132
|
+
return fn(self._c())
|
|
133
|
+
|
|
134
|
+
async def _async(self, fn: Callable[[sqlite3.Connection], Any]) -> Any:
|
|
135
|
+
self._c()
|
|
136
|
+
return await asyncio.get_running_loop().run_in_executor(self._pool, self._do, fn)
|
|
137
|
+
|
|
138
|
+
# statements
|
|
139
|
+
def exec_sync(self, source: str) -> None:
|
|
140
|
+
"""Runs one or more `;`-separated statements without parameters or results."""
|
|
141
|
+
self._do(lambda c: _script(c, source))
|
|
142
|
+
|
|
143
|
+
async def exec_async(self, source: str) -> None:
|
|
144
|
+
await self._async(lambda c: _script(c, source))
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def _run(c: sqlite3.Connection, sql: str, p: Any) -> SQLiteRunResult:
|
|
148
|
+
cur = c.execute(sql, p)
|
|
149
|
+
cur.fetchall()
|
|
150
|
+
return SQLiteRunResult(cur.lastrowid or 0, c.execute("SELECT changes()").fetchone()[0])
|
|
151
|
+
|
|
152
|
+
def run_sync(self, source: str, *params: Any) -> SQLiteRunResult:
|
|
153
|
+
p = _params(params)
|
|
154
|
+
return self._do(lambda c: self._run(c, source, p))
|
|
155
|
+
|
|
156
|
+
async def run_async(self, source: str, *params: Any) -> SQLiteRunResult:
|
|
157
|
+
p = _params(params)
|
|
158
|
+
return await self._async(lambda c: self._run(c, source, p))
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def _first(c: sqlite3.Connection, sql: str, p: Any) -> Optional[Row]:
|
|
162
|
+
cur = c.execute(sql, p)
|
|
163
|
+
rows = _rows(cur, cur.fetchmany(1))
|
|
164
|
+
cur.close()
|
|
165
|
+
return rows[0] if rows else None
|
|
166
|
+
|
|
167
|
+
def get_first_sync(self, source: str, *params: Any) -> Optional[Row]:
|
|
168
|
+
p = _params(params)
|
|
169
|
+
return self._do(lambda c: self._first(c, source, p))
|
|
170
|
+
|
|
171
|
+
async def get_first_async(self, source: str, *params: Any) -> Optional[Row]:
|
|
172
|
+
p = _params(params)
|
|
173
|
+
return await self._async(lambda c: self._first(c, source, p))
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def _all(c: sqlite3.Connection, sql: str, p: Any) -> List[Row]:
|
|
177
|
+
cur = c.execute(sql, p)
|
|
178
|
+
return _rows(cur, cur.fetchall())
|
|
179
|
+
|
|
180
|
+
def get_all_sync(self, source: str, *params: Any) -> List[Row]:
|
|
181
|
+
p = _params(params)
|
|
182
|
+
return self._do(lambda c: self._all(c, source, p))
|
|
183
|
+
|
|
184
|
+
async def get_all_async(self, source: str, *params: Any) -> List[Row]:
|
|
185
|
+
p = _params(params)
|
|
186
|
+
return await self._async(lambda c: self._all(c, source, p))
|
|
187
|
+
|
|
188
|
+
def get_each_sync(self, source: str, *params: Any) -> Iterator[Row]:
|
|
189
|
+
p = _params(params)
|
|
190
|
+
cur = self._do(lambda c: c.execute(source, p))
|
|
191
|
+
while True:
|
|
192
|
+
batch = self._do(lambda c: _rows(cur, cur.fetchmany(_BATCH)))
|
|
193
|
+
if not batch:
|
|
194
|
+
return
|
|
195
|
+
yield from batch
|
|
196
|
+
|
|
197
|
+
async def get_each_async(self, source: str, *params: Any) -> AsyncIterator[Row]:
|
|
198
|
+
"""Yields rows one by one, reading them in small batches."""
|
|
199
|
+
p = _params(params)
|
|
200
|
+
cur = await self._async(lambda c: c.execute(source, p))
|
|
201
|
+
try:
|
|
202
|
+
while True:
|
|
203
|
+
batch = await self._async(lambda c: _rows(cur, cur.fetchmany(_BATCH)))
|
|
204
|
+
if not batch:
|
|
205
|
+
return
|
|
206
|
+
for r in batch:
|
|
207
|
+
yield r
|
|
208
|
+
finally:
|
|
209
|
+
cur.close()
|
|
210
|
+
|
|
211
|
+
def prepare_sync(self, source: str) -> "SQLiteStatement":
|
|
212
|
+
self._c()
|
|
213
|
+
return SQLiteStatement(self, source)
|
|
214
|
+
|
|
215
|
+
async def prepare_async(self, source: str) -> "SQLiteStatement":
|
|
216
|
+
return self.prepare_sync(source)
|
|
217
|
+
|
|
218
|
+
# transactions
|
|
219
|
+
def is_in_transaction_sync(self) -> bool:
|
|
220
|
+
return self._do(lambda c: c.in_transaction)
|
|
221
|
+
|
|
222
|
+
async def is_in_transaction_async(self) -> bool:
|
|
223
|
+
return await self._async(lambda c: c.in_transaction)
|
|
224
|
+
|
|
225
|
+
async def with_transaction_async(self, task: Callable[[], Awaitable[Any]]) -> None:
|
|
226
|
+
"""Runs `task` inside BEGIN/COMMIT and rolls back if it raises. Other queries on this connection made
|
|
227
|
+
meanwhile (from other tasks) join the transaction: use `with_exclusive_transaction_async` to avoid that."""
|
|
228
|
+
await self.exec_async("BEGIN")
|
|
229
|
+
try:
|
|
230
|
+
await task()
|
|
231
|
+
except BaseException:
|
|
232
|
+
await self.exec_async("ROLLBACK")
|
|
233
|
+
raise
|
|
234
|
+
await self.exec_async("COMMIT")
|
|
235
|
+
|
|
236
|
+
async def with_exclusive_transaction_async(self, task: Callable[["SQLiteDatabase"], Awaitable[Any]]) -> None:
|
|
237
|
+
"""Runs `task(txn)` on a separate connection inside BEGIN EXCLUSIVE/COMMIT; use only `txn` inside it."""
|
|
238
|
+
if self.database_path == ":memory:":
|
|
239
|
+
raise sqlite3.NotSupportedError("an in-memory database has no second connection")
|
|
240
|
+
txn = SQLiteDatabase(self.database_name, self.database_path, SQLiteOpenOptions(use_new_connection=True))
|
|
241
|
+
try:
|
|
242
|
+
await txn.exec_async("BEGIN EXCLUSIVE")
|
|
243
|
+
try:
|
|
244
|
+
await task(txn)
|
|
245
|
+
except BaseException:
|
|
246
|
+
await txn.exec_async("ROLLBACK")
|
|
247
|
+
raise
|
|
248
|
+
await txn.exec_async("COMMIT")
|
|
249
|
+
finally:
|
|
250
|
+
await txn.close_async()
|
|
251
|
+
|
|
252
|
+
def with_transaction_sync(self, task: Callable[[], Any]) -> None:
|
|
253
|
+
with self._lock:
|
|
254
|
+
self.exec_sync("BEGIN")
|
|
255
|
+
try:
|
|
256
|
+
task()
|
|
257
|
+
except BaseException:
|
|
258
|
+
self.exec_sync("ROLLBACK")
|
|
259
|
+
raise
|
|
260
|
+
self.exec_sync("COMMIT")
|
|
261
|
+
|
|
262
|
+
# whole database
|
|
263
|
+
def serialize_sync(self, database_name: str = "main") -> bytes:
|
|
264
|
+
"""The database file as bytes (Python 3.11+)."""
|
|
265
|
+
_need_311("serialize")
|
|
266
|
+
return self._do(lambda c: c.serialize(name=database_name))
|
|
267
|
+
|
|
268
|
+
async def serialize_async(self, database_name: str = "main") -> bytes:
|
|
269
|
+
_need_311("serialize")
|
|
270
|
+
return await self._async(lambda c: c.serialize(name=database_name))
|
|
271
|
+
|
|
272
|
+
def close_sync(self) -> None:
|
|
273
|
+
"""Closes this connection. A shared connection closes when every opener closed it."""
|
|
274
|
+
with _open_lock:
|
|
275
|
+
self._refs -= 1
|
|
276
|
+
if self._refs > 0:
|
|
277
|
+
return
|
|
278
|
+
if _open.get(self.database_path) is self:
|
|
279
|
+
del _open[self.database_path]
|
|
280
|
+
with self._lock:
|
|
281
|
+
if self._conn is not None:
|
|
282
|
+
self._conn.close()
|
|
283
|
+
self._conn = None
|
|
284
|
+
self._pool.shutdown(wait=False)
|
|
285
|
+
|
|
286
|
+
async def close_async(self) -> None:
|
|
287
|
+
if self._conn is None:
|
|
288
|
+
return
|
|
289
|
+
# Wait for queued work, then close on the caller's side.
|
|
290
|
+
await asyncio.get_running_loop().run_in_executor(self._pool, lambda: None)
|
|
291
|
+
self.close_sync()
|
|
292
|
+
|
|
293
|
+
def __repr__(self) -> str:
|
|
294
|
+
return "<SQLiteDatabase %r>" % self.database_path
|
|
295
|
+
|
|
296
|
+
# Expo spellings
|
|
297
|
+
execAsync, execSync = exec_async, exec_sync
|
|
298
|
+
runAsync, runSync = run_async, run_sync
|
|
299
|
+
getFirstAsync, getFirstSync = get_first_async, get_first_sync
|
|
300
|
+
getAllAsync, getAllSync = get_all_async, get_all_sync
|
|
301
|
+
getEachAsync, getEachSync = get_each_async, get_each_sync
|
|
302
|
+
prepareAsync, prepareSync = prepare_async, prepare_sync
|
|
303
|
+
isInTransactionAsync, isInTransactionSync = is_in_transaction_async, is_in_transaction_sync
|
|
304
|
+
withTransactionAsync, withTransactionSync = with_transaction_async, with_transaction_sync
|
|
305
|
+
withExclusiveTransactionAsync = with_exclusive_transaction_async
|
|
306
|
+
serializeAsync, serializeSync = serialize_async, serialize_sync
|
|
307
|
+
closeAsync, closeSync = close_async, close_sync
|
|
308
|
+
databasePath = property(lambda self: self.database_path)
|
|
309
|
+
databaseName = property(lambda self: self.database_name)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class SQLiteExecuteResult:
|
|
313
|
+
"""What `SQLiteStatement.execute_*` returns: the run result plus the rows."""
|
|
314
|
+
|
|
315
|
+
def __init__(self, run: SQLiteRunResult, rows: List[Row]) -> None:
|
|
316
|
+
self.last_insert_row_id = run.last_insert_row_id
|
|
317
|
+
self.changes = run.changes
|
|
318
|
+
self._rows = rows
|
|
319
|
+
|
|
320
|
+
def get_first_sync(self) -> Optional[Row]:
|
|
321
|
+
return self._rows[0] if self._rows else None
|
|
322
|
+
|
|
323
|
+
def get_all_sync(self) -> List[Row]:
|
|
324
|
+
return list(self._rows)
|
|
325
|
+
|
|
326
|
+
async def get_first_async(self) -> Optional[Row]:
|
|
327
|
+
return self.get_first_sync()
|
|
328
|
+
|
|
329
|
+
async def get_all_async(self) -> List[Row]:
|
|
330
|
+
return self.get_all_sync()
|
|
331
|
+
|
|
332
|
+
def __iter__(self) -> Iterator[Row]:
|
|
333
|
+
return iter(list(self._rows))
|
|
334
|
+
|
|
335
|
+
async def __aiter__(self) -> AsyncIterator[Row]:
|
|
336
|
+
for r in list(self._rows):
|
|
337
|
+
yield r
|
|
338
|
+
|
|
339
|
+
lastInsertRowId = property(lambda self: self.last_insert_row_id)
|
|
340
|
+
getFirstAsync, getAllAsync = get_first_async, get_all_async
|
|
341
|
+
getFirstSync, getAllSync = get_first_sync, get_all_sync
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class SQLiteStatement:
|
|
345
|
+
"""A statement to run many times with different values. `sqlite3` caches the compiled statement."""
|
|
346
|
+
|
|
347
|
+
def __init__(self, db: SQLiteDatabase, source: str) -> None:
|
|
348
|
+
self._db = db
|
|
349
|
+
self.source = source
|
|
350
|
+
self._final = False
|
|
351
|
+
self._names: Optional[List[str]] = None
|
|
352
|
+
|
|
353
|
+
def _exec(self, c: sqlite3.Connection, p: Any) -> SQLiteExecuteResult:
|
|
354
|
+
if self._final:
|
|
355
|
+
raise sqlite3.ProgrammingError("the statement was finalized")
|
|
356
|
+
cur = c.execute(self.source, p)
|
|
357
|
+
rows = _rows(cur, cur.fetchall())
|
|
358
|
+
if cur.description:
|
|
359
|
+
self._names = [d[0] for d in cur.description]
|
|
360
|
+
changes = c.execute("SELECT changes()").fetchone()[0]
|
|
361
|
+
return SQLiteExecuteResult(SQLiteRunResult(cur.lastrowid or 0, changes), rows)
|
|
362
|
+
|
|
363
|
+
def execute_sync(self, *params: Any) -> SQLiteExecuteResult:
|
|
364
|
+
p = _params(params)
|
|
365
|
+
return self._db._do(lambda c: self._exec(c, p))
|
|
366
|
+
|
|
367
|
+
async def execute_async(self, *params: Any) -> SQLiteExecuteResult:
|
|
368
|
+
p = _params(params)
|
|
369
|
+
return await self._db._async(lambda c: self._exec(c, p))
|
|
370
|
+
|
|
371
|
+
def get_column_names_sync(self) -> List[str]:
|
|
372
|
+
"""Result column names. A query statement is compiled without running; other statements have none."""
|
|
373
|
+
if self._names is not None:
|
|
374
|
+
return list(self._names)
|
|
375
|
+
src = self.source.strip().rstrip(";")
|
|
376
|
+
if src[:6].upper() not in ("SELECT", "VALUES") and src[:4].upper() != "WITH":
|
|
377
|
+
return []
|
|
378
|
+
named = re.findall(r"[$:@]([A-Za-z_][A-Za-z0-9_]*)", src)
|
|
379
|
+
p: Any = {n: None for n in named} if named else [None] * src.count("?")
|
|
380
|
+
|
|
381
|
+
def names(c: sqlite3.Connection) -> List[str]:
|
|
382
|
+
cur = c.execute("SELECT * FROM (%s) LIMIT 0" % src, p)
|
|
383
|
+
return [d[0] for d in cur.description or ()]
|
|
384
|
+
return self._db._do(names)
|
|
385
|
+
|
|
386
|
+
async def get_column_names_async(self) -> List[str]:
|
|
387
|
+
return self.get_column_names_sync()
|
|
388
|
+
|
|
389
|
+
def finalize_sync(self) -> None:
|
|
390
|
+
self._final = True
|
|
391
|
+
|
|
392
|
+
async def finalize_async(self) -> None:
|
|
393
|
+
self._final = True
|
|
394
|
+
|
|
395
|
+
executeAsync, executeSync = execute_async, execute_sync
|
|
396
|
+
getColumnNamesAsync, getColumnNamesSync = get_column_names_async, get_column_names_sync
|
|
397
|
+
finalizeAsync, finalizeSync = finalize_async, finalize_sync
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
_open: Dict[str, SQLiteDatabase] = {}
|
|
401
|
+
_open_lock = threading.Lock()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _path(database_name: str, directory: Optional[str]) -> str:
|
|
405
|
+
if database_name == ":memory:":
|
|
406
|
+
return ":memory:"
|
|
407
|
+
if os.path.isabs(database_name):
|
|
408
|
+
return database_name
|
|
409
|
+
d = directory or _default_dir()
|
|
410
|
+
if d.startswith("file://"):
|
|
411
|
+
d = d[len("file://"):]
|
|
412
|
+
os.makedirs(d, exist_ok=True)
|
|
413
|
+
return os.path.join(d, database_name)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def open_database_sync(database_name: str, options: Optional[SQLiteOpenOptions] = None,
|
|
417
|
+
directory: Optional[str] = None) -> SQLiteDatabase:
|
|
418
|
+
"""Opens (and creates) `database_name` in `directory` (default `<documents>/SQLite`). `":memory:"` opens a
|
|
419
|
+
private in-memory database. Opening the same file again shares the connection unless
|
|
420
|
+
`SQLiteOpenOptions(use_new_connection=True)`."""
|
|
421
|
+
opts = options or SQLiteOpenOptions()
|
|
422
|
+
path = _path(database_name, directory)
|
|
423
|
+
with _open_lock:
|
|
424
|
+
cur = _open.get(path)
|
|
425
|
+
if cur is not None and not opts.use_new_connection and path != ":memory:":
|
|
426
|
+
cur._refs += 1
|
|
427
|
+
return cur
|
|
428
|
+
db = SQLiteDatabase(database_name, path, opts)
|
|
429
|
+
if not opts.use_new_connection and path != ":memory:":
|
|
430
|
+
_open[path] = db
|
|
431
|
+
return db
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
async def open_database_async(database_name: str, options: Optional[SQLiteOpenOptions] = None,
|
|
435
|
+
directory: Optional[str] = None) -> SQLiteDatabase:
|
|
436
|
+
return await asyncio.to_thread(open_database_sync, database_name, options, directory)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def delete_database_sync(database_name: str, directory: Optional[str] = None) -> None:
|
|
440
|
+
"""Deletes the database file and its -wal/-shm/-journal files. Fails while the database is open."""
|
|
441
|
+
path = _path(database_name, directory)
|
|
442
|
+
if path in _open:
|
|
443
|
+
raise sqlite3.OperationalError("close the database %r before deleting it" % database_name)
|
|
444
|
+
if not os.path.exists(path):
|
|
445
|
+
raise FileNotFoundError("no database %r" % path)
|
|
446
|
+
for suffix in ("", "-wal", "-shm", "-journal"):
|
|
447
|
+
if os.path.exists(path + suffix):
|
|
448
|
+
os.remove(path + suffix)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
async def delete_database_async(database_name: str, directory: Optional[str] = None) -> None:
|
|
452
|
+
await asyncio.to_thread(delete_database_sync, database_name, directory)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def deserialize_database_sync(data: bytes) -> SQLiteDatabase:
|
|
456
|
+
"""Opens an in-memory database with the contents of `data` (from `serialize_*`; Python 3.11+)."""
|
|
457
|
+
_need_311("deserialize")
|
|
458
|
+
db = SQLiteDatabase(":memory:", ":memory:", SQLiteOpenOptions(use_new_connection=True))
|
|
459
|
+
db._do(lambda c: c.deserialize(data))
|
|
460
|
+
return db
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
async def deserialize_database_async(data: bytes) -> SQLiteDatabase:
|
|
464
|
+
return deserialize_database_sync(data)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
# Provider and hook, as expo-sqlite's <SQLiteProvider> and useSQLiteContext().
|
|
468
|
+
def _make_provider() -> Tuple[Any, Callable[[], SQLiteDatabase]]:
|
|
469
|
+
from pyxpo import component, create_context, use_async, use_context, use_effect
|
|
470
|
+
|
|
471
|
+
ctx = create_context(None)
|
|
472
|
+
|
|
473
|
+
@component
|
|
474
|
+
def SQLiteProvider(*children: Any, database_name: str, options: Optional[SQLiteOpenOptions] = None,
|
|
475
|
+
directory: Optional[str] = None, on_init: Optional[Callable[[SQLiteDatabase], Awaitable[Any]]] = None,
|
|
476
|
+
on_error: Optional[Callable[[BaseException], Any]] = None, fallback: Any = None) -> Any:
|
|
477
|
+
async def open_() -> SQLiteDatabase:
|
|
478
|
+
db = await open_database_async(database_name, options, directory)
|
|
479
|
+
if on_init is not None:
|
|
480
|
+
await on_init(db)
|
|
481
|
+
return db
|
|
482
|
+
|
|
483
|
+
res = use_async(open_, [database_name, directory])
|
|
484
|
+
|
|
485
|
+
def close() -> Callable[[], None]:
|
|
486
|
+
db = res.value
|
|
487
|
+
return lambda: db.close_sync() if db is not None else None
|
|
488
|
+
use_effect(close, [res.value])
|
|
489
|
+
if res.error is not None:
|
|
490
|
+
if on_error is None:
|
|
491
|
+
raise res.error
|
|
492
|
+
on_error(res.error)
|
|
493
|
+
return fallback
|
|
494
|
+
if res.loading:
|
|
495
|
+
return fallback
|
|
496
|
+
return ctx.Provider(*children, value=res.value)
|
|
497
|
+
|
|
498
|
+
def use_sqlite_context() -> SQLiteDatabase:
|
|
499
|
+
db = use_context(ctx)
|
|
500
|
+
if db is None:
|
|
501
|
+
raise RuntimeError("use_sqlite_context() needs an SQLiteProvider above it")
|
|
502
|
+
return db
|
|
503
|
+
|
|
504
|
+
return SQLiteProvider, use_sqlite_context
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
_provider: Optional[Tuple[Any, Callable[[], SQLiteDatabase]]] = None
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _get_provider() -> Tuple[Any, Callable[[], SQLiteDatabase]]:
|
|
511
|
+
global _provider
|
|
512
|
+
if _provider is None:
|
|
513
|
+
_provider = _make_provider()
|
|
514
|
+
return _provider
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def SQLiteProvider(*children: Any, **props: Any) -> Any: # noqa: N802
|
|
518
|
+
"""Opens `database_name`, runs `on_init(db)` (migrations), then renders `children` with the database
|
|
519
|
+
available to `use_sqlite_context()`. Shows `fallback` (default nothing) while opening."""
|
|
520
|
+
return _get_provider()[0](*children, **props)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def use_sqlite_context() -> SQLiteDatabase:
|
|
524
|
+
return _get_provider()[1]()
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
openDatabaseAsync, openDatabaseSync = open_database_async, open_database_sync
|
|
528
|
+
deleteDatabaseAsync, deleteDatabaseSync = delete_database_async, delete_database_sync
|
|
529
|
+
deserializeDatabaseAsync, deserializeDatabaseSync = deserialize_database_async, deserialize_database_sync
|
|
530
|
+
useSQLiteContext = use_sqlite_context
|
|
531
|
+
|
|
532
|
+
__all__ = ["open_database_async", "open_database_sync", "delete_database_async", "delete_database_sync",
|
|
533
|
+
"deserialize_database_async", "deserialize_database_sync", "SQLiteDatabase", "SQLiteStatement",
|
|
534
|
+
"SQLiteRunResult", "SQLiteExecuteResult", "SQLiteOpenOptions", "SQLiteProvider", "use_sqlite_context"]
|
|
File without changes
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyxpo-sqlite
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: SQLite databases for pyxpo apps (expo-sqlite API)
|
|
5
|
+
Author: Vishal Chaubey
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/pyxpo/pyxpo/tree/main/packages/sqlite
|
|
8
|
+
Project-URL: Issues, https://github.com/pyxpo/pyxpo/issues
|
|
9
|
+
Keywords: pyxpo,pyxpo-module,mobile,android,ios
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: User Interfaces
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: pyxpo<0.2,>=0.1.0a2
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# pyxpo-sqlite
|
|
21
|
+
|
|
22
|
+
SQLite databases that persist across app restarts, for [pyxpo](https://github.com/pyxpo/pyxpo) apps. The API
|
|
23
|
+
follows [expo-sqlite](https://docs.expo.dev/versions/latest/sdk/sqlite/). The phone's Python ships the `sqlite3`
|
|
24
|
+
module, so this package is pure Python: no native code and no rebuild of the native shell.
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pip install pyxpo-sqlite
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```toml
|
|
31
|
+
[tool.pyxpo]
|
|
32
|
+
modules = ["sqlite"]
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import pyxpo_sqlite as SQLite
|
|
37
|
+
|
|
38
|
+
db = await SQLite.open_database_async("app.db")
|
|
39
|
+
await db.exec_async("""
|
|
40
|
+
PRAGMA journal_mode = WAL;
|
|
41
|
+
CREATE TABLE IF NOT EXISTS todo (id INTEGER PRIMARY KEY, title TEXT NOT NULL, done INTEGER DEFAULT 0);
|
|
42
|
+
""")
|
|
43
|
+
res = await db.run_async("INSERT INTO todo (title) VALUES (?)", "Buy milk")
|
|
44
|
+
print(res.last_insert_row_id, res.changes)
|
|
45
|
+
|
|
46
|
+
row = await db.get_first_async("SELECT * FROM todo WHERE id = ?", res.last_insert_row_id) # dict or None
|
|
47
|
+
rows = await db.get_all_async("SELECT * FROM todo WHERE done = $done", {"$done": 0}) # list of dicts
|
|
48
|
+
async for row in db.get_each_async("SELECT * FROM todo"): # one at a time
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
async def mark_all():
|
|
52
|
+
await db.run_async("UPDATE todo SET done = 1")
|
|
53
|
+
await db.with_transaction_async(mark_all) # COMMIT, or ROLLBACK if mark_all raises
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Functions
|
|
57
|
+
|
|
58
|
+
| Function | Notes |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| `open_database_async(name, options=None, directory=None)` | Opens or creates `name` in `directory` (default `default_database_directory`). `":memory:"` opens a private in-memory database. Opening the same file again returns the same connection unless `SQLiteOpenOptions(use_new_connection=True)`. |
|
|
61
|
+
| `delete_database_async(name, directory=None)` | Deletes the file and its `-wal`, `-shm` and `-journal` files. Raises while the database is open. |
|
|
62
|
+
| `deserialize_database_async(data)` | An in-memory database holding `data` from `serialize_async()`. |
|
|
63
|
+
| `default_database_directory` | `<documents>/SQLite`: the app's Documents folder on iOS, `filesDir` on Android. |
|
|
64
|
+
|
|
65
|
+
## `SQLiteDatabase`
|
|
66
|
+
|
|
67
|
+
| Method | Notes |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| `exec_async(sql)` | Runs `;`-separated statements without values or results (schema, PRAGMA). |
|
|
70
|
+
| `run_async(sql, *values)` | Returns `SQLiteRunResult(last_insert_row_id, changes)`. |
|
|
71
|
+
| `get_first_async(sql, *values)` | First row as a dict, or `None`. |
|
|
72
|
+
| `get_all_async(sql, *values)` | All rows as dicts. |
|
|
73
|
+
| `get_each_async(sql, *values)` | Async iterator of rows, read in small batches. |
|
|
74
|
+
| `prepare_async(sql)` | `SQLiteStatement`: `execute_async(*values)` returns a result with `last_insert_row_id`, `changes`, `get_first_async()`, `get_all_async()` and async iteration; also `get_column_names_async()` and `finalize_async()`. |
|
|
75
|
+
| `with_transaction_async(task)` | Runs `await task()` inside `BEGIN`/`COMMIT`, rolling back if it raises. Queries from other tasks on the same connection meanwhile join the transaction. |
|
|
76
|
+
| `with_exclusive_transaction_async(task)` | Runs `await task(txn)` on a separate connection inside `BEGIN EXCLUSIVE`; run queries on `txn` only. Not for `":memory:"`. |
|
|
77
|
+
| `is_in_transaction_async()` | |
|
|
78
|
+
| `serialize_async(name="main")` | The database as bytes. |
|
|
79
|
+
| `close_async()` | A shared connection closes when every opener closed it. |
|
|
80
|
+
| `database_path`, `database_name` | |
|
|
81
|
+
|
|
82
|
+
Values: positional (`run_async(sql, 1, "a")`), one list or tuple, or one dict for named parameters. Dict keys may
|
|
83
|
+
keep the prefix used in the SQL (`{"$id": 1}` for `$id`, `:id`, `@id`) or drop it. Rows map column names to
|
|
84
|
+
Python values: `int`, `float`, `str`, `bytes` or `None`.
|
|
85
|
+
|
|
86
|
+
Every method also has a `_sync` form that blocks (for example `get_all_sync`), and camelCase names work
|
|
87
|
+
(`getAllAsync`, `runAsync`, `lastInsertRowId`). Async methods run on one worker thread per connection, so the UI
|
|
88
|
+
stays responsive. SQL errors raise the `sqlite3` exceptions (`sqlite3.OperationalError`,
|
|
89
|
+
`sqlite3.IntegrityError`, ...).
|
|
90
|
+
|
|
91
|
+
## Provider and hook
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from pyxpo_sqlite import SQLiteProvider, use_sqlite_context
|
|
95
|
+
|
|
96
|
+
async def migrate(db):
|
|
97
|
+
await db.exec_async("CREATE TABLE IF NOT EXISTS todo (id INTEGER PRIMARY KEY, title TEXT)")
|
|
98
|
+
|
|
99
|
+
def App():
|
|
100
|
+
return SQLiteProvider(TodoList(), database_name="app.db", on_init=migrate, fallback=Text("Loading..."))
|
|
101
|
+
|
|
102
|
+
@component
|
|
103
|
+
def TodoList():
|
|
104
|
+
db = use_sqlite_context()
|
|
105
|
+
...
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`SQLiteProvider` props: `database_name`, `options`, `directory`, `on_init(db)` (awaited before the children render),
|
|
109
|
+
`on_error(error)` (without it the error is raised to the nearest `ErrorBoundary`) and `fallback`.
|
|
110
|
+
|
|
111
|
+
## Not supported
|
|
112
|
+
|
|
113
|
+
Change listeners (`addDatabaseChangeListener`), `enableChangeListener`, SQLCipher encryption, loading extensions
|
|
114
|
+
and the key-value `Storage` export of expo-sqlite (use `pyxpo-storage`).
|
|
115
|
+
|
|
116
|
+
## Platforms
|
|
117
|
+
|
|
118
|
+
iOS, Android and desktop. The bundled Python on iOS and Android is 3.13. On desktop, `serialize` and
|
|
119
|
+
`deserialize` need Python 3.11 or later and raise `sqlite3.NotSupportedError` before that. On desktop, databases
|
|
120
|
+
live in `.pyxpo-data/documents/SQLite` under the working directory.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
pyxpo_sqlite/__init__.py
|
|
5
|
+
pyxpo_sqlite/py.typed
|
|
6
|
+
pyxpo_sqlite/pyxpo-module.json
|
|
7
|
+
pyxpo_sqlite.egg-info/PKG-INFO
|
|
8
|
+
pyxpo_sqlite.egg-info/SOURCES.txt
|
|
9
|
+
pyxpo_sqlite.egg-info/dependency_links.txt
|
|
10
|
+
pyxpo_sqlite.egg-info/entry_points.txt
|
|
11
|
+
pyxpo_sqlite.egg-info/requires.txt
|
|
12
|
+
pyxpo_sqlite.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyxpo<0.2,>=0.1.0a2
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyxpo_sqlite
|