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.
@@ -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,6 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "nativeContract": 1,
4
+ "name": "sqlite",
5
+ "native": "SQLite"
6
+ }
@@ -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,2 @@
1
+ [pyxpo.modules]
2
+ sqlite = pyxpo_sqlite
@@ -0,0 +1 @@
1
+ pyxpo<0.2,>=0.1.0a2
@@ -0,0 +1 @@
1
+ pyxpo_sqlite
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+