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 ADDED
@@ -0,0 +1,6 @@
1
+ """dirsql - Ephemeral SQL index over a local directory."""
2
+
3
+ from dirsql._dirsql import Table, RowEvent, __version__
4
+ from dirsql._async import DirSQL
5
+
6
+ __all__ = ["DirSQL", "Table", "RowEvent", "__version__"]
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: maturin (1.13.1)
3
+ Root-Is-Purelib: false
4
+ Tag: cp313-cp313-win_amd64