dirsql 0.0.26__cp313-cp313-win_amd64.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.
- dirsql/__init__.py +6 -0
- dirsql/_async.py +104 -0
- dirsql/_dirsql.cp313-win_amd64.pyd +0 -0
- dirsql/test_async.py +78 -0
- dirsql-0.0.26.dist-info/METADATA +209 -0
- dirsql-0.0.26.dist-info/RECORD +8 -0
- dirsql-0.0.26.dist-info/WHEEL +4 -0
- dirsql-0.0.26.dist-info/sboms/dirsql-py-ext.cyclonedx.json +2880 -0
dirsql/__init__.py
ADDED
dirsql/_async.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Async-by-default DirSQL wrapper."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from dirsql._dirsql import DirSQL as _RustDirSQL
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _WatchStream:
|
|
9
|
+
"""Async iterator that polls for file events."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, db):
|
|
12
|
+
self._db = db
|
|
13
|
+
self._started = False
|
|
14
|
+
self._buffer = []
|
|
15
|
+
|
|
16
|
+
def __aiter__(self):
|
|
17
|
+
return self
|
|
18
|
+
|
|
19
|
+
async def __anext__(self):
|
|
20
|
+
if not self._started:
|
|
21
|
+
await asyncio.to_thread(self._db._start_watcher)
|
|
22
|
+
self._started = True
|
|
23
|
+
|
|
24
|
+
while True:
|
|
25
|
+
if self._buffer:
|
|
26
|
+
return self._buffer.pop(0)
|
|
27
|
+
events = await asyncio.to_thread(self._db._poll_events, 200)
|
|
28
|
+
if events:
|
|
29
|
+
self._buffer.extend(events)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DirSQL:
|
|
33
|
+
"""Async-by-default wrapper around the Rust DirSQL engine.
|
|
34
|
+
|
|
35
|
+
Usage:
|
|
36
|
+
db = DirSQL(root, tables=[...])
|
|
37
|
+
await db.ready()
|
|
38
|
+
results = await db.query("SELECT ...")
|
|
39
|
+
async for event in db.watch():
|
|
40
|
+
...
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, root, *, tables, ignore=None):
|
|
44
|
+
self._root = root
|
|
45
|
+
self._tables = tables
|
|
46
|
+
self._ignore = ignore
|
|
47
|
+
self._db = None
|
|
48
|
+
self._ready_event = asyncio.Event()
|
|
49
|
+
self._init_error = None
|
|
50
|
+
self._task = asyncio.ensure_future(self._init_bg())
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_config(cls, path):
|
|
54
|
+
"""Create a DirSQL from a .dirsql.toml config file.
|
|
55
|
+
|
|
56
|
+
Returns a DirSQL instance. Call ``await db.ready()`` before querying.
|
|
57
|
+
"""
|
|
58
|
+
instance = object.__new__(cls)
|
|
59
|
+
instance._root = None
|
|
60
|
+
instance._tables = None
|
|
61
|
+
instance._ignore = None
|
|
62
|
+
instance._db = None
|
|
63
|
+
instance._ready_event = asyncio.Event()
|
|
64
|
+
instance._init_error = None
|
|
65
|
+
instance._task = asyncio.ensure_future(instance._init_from_config(path))
|
|
66
|
+
return instance
|
|
67
|
+
|
|
68
|
+
async def _init_from_config(self, path):
|
|
69
|
+
"""Run from_config scan in the background."""
|
|
70
|
+
try:
|
|
71
|
+
self._db = await asyncio.to_thread(_RustDirSQL.from_config, path)
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
self._init_error = exc
|
|
74
|
+
finally:
|
|
75
|
+
self._ready_event.set()
|
|
76
|
+
|
|
77
|
+
async def _init_bg(self):
|
|
78
|
+
"""Run the scan in the background."""
|
|
79
|
+
try:
|
|
80
|
+
self._db = await asyncio.to_thread(
|
|
81
|
+
_RustDirSQL, self._root, tables=self._tables, ignore=self._ignore
|
|
82
|
+
)
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
self._init_error = exc
|
|
85
|
+
finally:
|
|
86
|
+
self._ready_event.set()
|
|
87
|
+
|
|
88
|
+
async def ready(self):
|
|
89
|
+
"""Wait until the initial scan is complete.
|
|
90
|
+
|
|
91
|
+
Raises any exception that occurred during init.
|
|
92
|
+
Can be called multiple times safely.
|
|
93
|
+
"""
|
|
94
|
+
await self._ready_event.wait()
|
|
95
|
+
if self._init_error is not None:
|
|
96
|
+
raise self._init_error
|
|
97
|
+
|
|
98
|
+
async def query(self, sql):
|
|
99
|
+
"""Execute a SQL query asynchronously."""
|
|
100
|
+
return await asyncio.to_thread(self._db.query, sql)
|
|
101
|
+
|
|
102
|
+
def watch(self):
|
|
103
|
+
"""Start watching for file changes. Returns an async iterable of RowEvent."""
|
|
104
|
+
return _WatchStream(self._db)
|
|
Binary file
|
dirsql/test_async.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Unit tests for the DirSQL async wrapper."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from dirsql import _async as async_mod
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _FakeRustDirSQL:
|
|
9
|
+
def __init__(self, root, *, tables, ignore=None):
|
|
10
|
+
self.root = root
|
|
11
|
+
self.tables = tables
|
|
12
|
+
self.ignore = ignore
|
|
13
|
+
self.query_calls = []
|
|
14
|
+
|
|
15
|
+
def query(self, sql):
|
|
16
|
+
self.query_calls.append(sql)
|
|
17
|
+
return [{"sql": sql}]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _FakeWatcherDb:
|
|
21
|
+
def __init__(self, events):
|
|
22
|
+
self.events = list(events)
|
|
23
|
+
self.started = 0
|
|
24
|
+
self.poll_calls = []
|
|
25
|
+
|
|
26
|
+
def _start_watcher(self):
|
|
27
|
+
self.started += 1
|
|
28
|
+
|
|
29
|
+
def _poll_events(self, timeout_ms):
|
|
30
|
+
self.poll_calls.append(timeout_ms)
|
|
31
|
+
if self.events:
|
|
32
|
+
return self.events.pop(0)
|
|
33
|
+
return []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.asyncio
|
|
37
|
+
async def test_ready_and_query_use_the_background_db(monkeypatch):
|
|
38
|
+
monkeypatch.setattr(async_mod, "_RustDirSQL", _FakeRustDirSQL)
|
|
39
|
+
|
|
40
|
+
db = async_mod.DirSQL("/tmp/root", tables=["table-a"], ignore=["**/*.tmp"])
|
|
41
|
+
await db.ready()
|
|
42
|
+
|
|
43
|
+
results = await db.query("SELECT 1")
|
|
44
|
+
|
|
45
|
+
assert db._db.root == "/tmp/root"
|
|
46
|
+
assert db._db.tables == ["table-a"]
|
|
47
|
+
assert db._db.ignore == ["**/*.tmp"]
|
|
48
|
+
assert db._db.query_calls == ["SELECT 1"]
|
|
49
|
+
assert results == [{"sql": "SELECT 1"}]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.asyncio
|
|
53
|
+
async def test_ready_propagates_initialization_errors(monkeypatch):
|
|
54
|
+
class _BoomDirSQL:
|
|
55
|
+
def __init__(self, *args, **kwargs):
|
|
56
|
+
raise RuntimeError("boom")
|
|
57
|
+
|
|
58
|
+
monkeypatch.setattr(async_mod, "_RustDirSQL", _BoomDirSQL)
|
|
59
|
+
|
|
60
|
+
db = async_mod.DirSQL("/tmp/root", tables=["table-a"])
|
|
61
|
+
|
|
62
|
+
with pytest.raises(RuntimeError, match="boom"):
|
|
63
|
+
await db.ready()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.asyncio
|
|
67
|
+
async def test_watch_stream_starts_and_buffers_events():
|
|
68
|
+
stream = async_mod._WatchStream(_FakeWatcherDb(events=[["event-a", "event-b"]]))
|
|
69
|
+
|
|
70
|
+
assert stream.__aiter__() is stream
|
|
71
|
+
|
|
72
|
+
first = await stream.__anext__()
|
|
73
|
+
second = await stream.__anext__()
|
|
74
|
+
|
|
75
|
+
assert first == "event-a"
|
|
76
|
+
assert second == "event-b"
|
|
77
|
+
assert stream._db.started == 1
|
|
78
|
+
assert stream._db.poll_calls == [200]
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dirsql
|
|
3
|
+
Version: 0.0.26
|
|
4
|
+
Requires-Dist: pytest>=8 ; extra == 'dev'
|
|
5
|
+
Requires-Dist: pytest-describe>=2 ; extra == 'dev'
|
|
6
|
+
Requires-Dist: pytest-asyncio>=0.23 ; extra == 'dev'
|
|
7
|
+
Requires-Dist: pytest-cov>=5 ; extra == 'dev'
|
|
8
|
+
Requires-Dist: ruff>=0.4 ; extra == 'dev'
|
|
9
|
+
Requires-Dist: maturin>=1.0 ; extra == 'dev'
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Summary: Ephemeral SQL index over a local directory
|
|
12
|
+
Keywords: sql,filesystem,directory,sqlite,index
|
|
13
|
+
Author: Kevin Scott
|
|
14
|
+
License-Expression: MIT
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
17
|
+
|
|
18
|
+
# `dirsql` (Python SDK)
|
|
19
|
+
|
|
20
|
+
Ephemeral SQL index over a local directory. Watches a filesystem, ingests structured files into an in-memory SQLite database, and exposes a SQL query interface. The database is purely in-memory -- the filesystem is always the source of truth.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install dirsql
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Requires Python >= 3.12. Ships as a native extension (Rust via PyO3) -- binary wheels are provided for common platforms.
|
|
29
|
+
|
|
30
|
+
## Quick Start
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import asyncio
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import tempfile
|
|
37
|
+
from dirsql import DirSQL, Table
|
|
38
|
+
|
|
39
|
+
async def main():
|
|
40
|
+
# Create some data files
|
|
41
|
+
root = tempfile.mkdtemp()
|
|
42
|
+
os.makedirs(os.path.join(root, "comments", "abc"), exist_ok=True)
|
|
43
|
+
os.makedirs(os.path.join(root, "comments", "def"), exist_ok=True)
|
|
44
|
+
|
|
45
|
+
with open(os.path.join(root, "comments", "abc", "index.jsonl"), "w") as f:
|
|
46
|
+
f.write(json.dumps({"body": "looks good", "author": "alice"}) + "\n")
|
|
47
|
+
f.write(json.dumps({"body": "needs work", "author": "bob"}) + "\n")
|
|
48
|
+
|
|
49
|
+
with open(os.path.join(root, "comments", "def", "index.jsonl"), "w") as f:
|
|
50
|
+
f.write(json.dumps({"body": "agreed", "author": "carol"}) + "\n")
|
|
51
|
+
|
|
52
|
+
# Define a table: DDL, glob pattern, and an extract function
|
|
53
|
+
db = DirSQL(
|
|
54
|
+
root,
|
|
55
|
+
tables=[
|
|
56
|
+
Table(
|
|
57
|
+
ddl="CREATE TABLE comments (id TEXT, body TEXT, author TEXT)",
|
|
58
|
+
glob="comments/**/index.jsonl",
|
|
59
|
+
extract=lambda path, content: [
|
|
60
|
+
{
|
|
61
|
+
"id": os.path.basename(os.path.dirname(path)),
|
|
62
|
+
"body": row["body"],
|
|
63
|
+
"author": row["author"],
|
|
64
|
+
}
|
|
65
|
+
for line in content.splitlines()
|
|
66
|
+
for row in [json.loads(line)]
|
|
67
|
+
],
|
|
68
|
+
),
|
|
69
|
+
],
|
|
70
|
+
)
|
|
71
|
+
await db.ready()
|
|
72
|
+
|
|
73
|
+
# Query with SQL
|
|
74
|
+
results = await db.query("SELECT * FROM comments WHERE author = 'alice'")
|
|
75
|
+
# [{"id": "abc", "body": "looks good", "author": "alice"}]
|
|
76
|
+
|
|
77
|
+
asyncio.run(main())
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Multiple Tables and Joins
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
db = DirSQL(
|
|
84
|
+
root,
|
|
85
|
+
tables=[
|
|
86
|
+
Table(
|
|
87
|
+
ddl="CREATE TABLE posts (title TEXT, author_id TEXT)",
|
|
88
|
+
glob="posts/*.json",
|
|
89
|
+
extract=lambda path, content: [json.loads(content)],
|
|
90
|
+
),
|
|
91
|
+
Table(
|
|
92
|
+
ddl="CREATE TABLE authors (id TEXT, name TEXT)",
|
|
93
|
+
glob="authors/*.json",
|
|
94
|
+
extract=lambda path, content: [json.loads(content)],
|
|
95
|
+
),
|
|
96
|
+
],
|
|
97
|
+
)
|
|
98
|
+
await db.ready()
|
|
99
|
+
|
|
100
|
+
results = await db.query("""
|
|
101
|
+
SELECT posts.title, authors.name
|
|
102
|
+
FROM posts JOIN authors ON posts.author_id = authors.id
|
|
103
|
+
""")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Ignoring Files
|
|
107
|
+
|
|
108
|
+
Pass `ignore` patterns to skip files during scanning and watching:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
db = DirSQL(
|
|
112
|
+
root,
|
|
113
|
+
ignore=["**/drafts/**", "**/.git/**"],
|
|
114
|
+
tables=[...],
|
|
115
|
+
)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Watching for Changes
|
|
119
|
+
|
|
120
|
+
`DirSQL` is async by default. The `watch()` method returns an async iterator of row-level change events.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
import asyncio
|
|
124
|
+
import json
|
|
125
|
+
from dirsql import DirSQL, Table
|
|
126
|
+
|
|
127
|
+
async def main():
|
|
128
|
+
db = DirSQL(
|
|
129
|
+
"/path/to/data",
|
|
130
|
+
tables=[
|
|
131
|
+
Table(
|
|
132
|
+
ddl="CREATE TABLE items (name TEXT)",
|
|
133
|
+
glob="**/*.json",
|
|
134
|
+
extract=lambda path, content: [json.loads(content)],
|
|
135
|
+
),
|
|
136
|
+
],
|
|
137
|
+
)
|
|
138
|
+
await db.ready()
|
|
139
|
+
|
|
140
|
+
# Query
|
|
141
|
+
results = await db.query("SELECT * FROM items")
|
|
142
|
+
|
|
143
|
+
# Watch for file changes (insert/update/delete/error events)
|
|
144
|
+
async for event in db.watch():
|
|
145
|
+
print(f"{event.action} on {event.table}: {event.row}")
|
|
146
|
+
if event.action == "error":
|
|
147
|
+
print(f" error: {event.error}")
|
|
148
|
+
|
|
149
|
+
asyncio.run(main())
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## API Reference
|
|
153
|
+
|
|
154
|
+
### `Table(*, ddl, glob, extract)`
|
|
155
|
+
|
|
156
|
+
Defines how files map to a SQL table.
|
|
157
|
+
|
|
158
|
+
- **`ddl`** (`str`): A `CREATE TABLE` statement defining the schema.
|
|
159
|
+
- **`glob`** (`str`): A glob pattern matched against file paths relative to root.
|
|
160
|
+
- **`extract`** (`Callable[[str, str], list[dict]]`): A function receiving `(relative_path, file_content)` and returning a list of row dicts. Each dict's keys must match the DDL column names.
|
|
161
|
+
|
|
162
|
+
### `DirSQL(root, *, tables, ignore=None)`
|
|
163
|
+
|
|
164
|
+
Creates an in-memory SQLite database indexed from the directory at `root`. The constructor is sync and returns immediately; scanning runs in a background thread.
|
|
165
|
+
|
|
166
|
+
- **`root`** (`str`): Path to the directory to index.
|
|
167
|
+
- **`tables`** (`list[Table]`): Table definitions.
|
|
168
|
+
- **`ignore`** (`list[str] | None`): Glob patterns for paths to skip.
|
|
169
|
+
|
|
170
|
+
#### `await DirSQL.ready()`
|
|
171
|
+
|
|
172
|
+
Wait for the initial scan to complete. Idempotent -- safe to call multiple times. Raises any exception that occurred during init.
|
|
173
|
+
|
|
174
|
+
#### `await DirSQL.query(sql) -> list[dict]`
|
|
175
|
+
|
|
176
|
+
Execute a SQL query. Returns a list of dicts keyed by column name. Internal tracking columns (`_dirsql_*`) are excluded from results.
|
|
177
|
+
|
|
178
|
+
#### `DirSQL.watch() -> AsyncIterator[RowEvent]`
|
|
179
|
+
|
|
180
|
+
Returns an async iterator that yields `RowEvent` objects as files change on disk. Starts the filesystem watcher on first iteration.
|
|
181
|
+
|
|
182
|
+
#### `DirSQL.from_config(path) -> DirSQL`
|
|
183
|
+
|
|
184
|
+
Create a `DirSQL` instance from a `.dirsql.toml` config file. Returns immediately; scanning runs in the background. Call `await db.ready()` before querying.
|
|
185
|
+
|
|
186
|
+
### `RowEvent`
|
|
187
|
+
|
|
188
|
+
Emitted by `watch()` when a file change produces row-level diffs.
|
|
189
|
+
|
|
190
|
+
- **`table`** (`str`): The affected table name.
|
|
191
|
+
- **`action`** (`str`): One of `"insert"`, `"update"`, `"delete"`, `"error"`.
|
|
192
|
+
- **`row`** (`dict | None`): The new row (for insert/update) or deleted row (for delete).
|
|
193
|
+
- **`old_row`** (`dict | None`): The previous row (for update only).
|
|
194
|
+
- **`error`** (`str | None`): Error message (for error events).
|
|
195
|
+
- **`file_path`** (`str | None`): The relative file path that triggered the event.
|
|
196
|
+
|
|
197
|
+
## How It Works
|
|
198
|
+
|
|
199
|
+
The Rust core (`rusqlite` + `notify` + `walkdir`) does the heavy lifting:
|
|
200
|
+
|
|
201
|
+
1. **Startup scan**: Walks the directory tree, matches files to tables via glob patterns, calls the user-provided `extract` function for each file, and inserts rows into an in-memory SQLite database.
|
|
202
|
+
2. **File watching**: Uses the `notify` crate (inotify on Linux, FSEvents on macOS) to detect file creates, modifications, and deletions.
|
|
203
|
+
3. **Row diffing**: When a file changes, the new rows are diffed against the previous rows for that file, producing granular insert/update/delete events.
|
|
204
|
+
4. **Python bindings**: PyO3 exposes the Rust core as a native Python extension module. The async layer runs blocking operations in a thread pool via `asyncio.to_thread`.
|
|
205
|
+
|
|
206
|
+
## License
|
|
207
|
+
|
|
208
|
+
MIT
|
|
209
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
dirsql/__init__.py,sha256=rL-bI8j4OUx7DY-fG2chvnS7EsrH0-uWLdjYxyhtNJc,213
|
|
2
|
+
dirsql/_async.py,sha256=FOMPeMojhsCqgbyZ3DYdDxkZn7Khrgke_NYIiubn8pE,3209
|
|
3
|
+
dirsql/_dirsql.cp313-win_amd64.pyd,sha256=fao9eetRBHX6uCU0cgCGTotIp0hNzs4k1Gx1yp5FJ5M,4903936
|
|
4
|
+
dirsql/test_async.py,sha256=XuPb9cW6EKU-xVRZ915-_VN-miKjBO0pYZctMDCNrp8,2164
|
|
5
|
+
dirsql-0.0.26.dist-info/METADATA,sha256=xw1P3oonTN7wx358fZvfpgIrRFOKsNq0BrbxUs65_X4,7300
|
|
6
|
+
dirsql-0.0.26.dist-info/WHEEL,sha256=TDIE_VZkwaFwJY0Olvlg1LUe0IwzGBwZhAVXfIDdWJc,97
|
|
7
|
+
dirsql-0.0.26.dist-info/sboms/dirsql-py-ext.cyclonedx.json,sha256=3NNQTWahR01F2sRAy0eLRSyb5u7gkNcTUb60vtZUE9M,89230
|
|
8
|
+
dirsql-0.0.26.dist-info/RECORD,,
|