dirsql 0.0.23__tar.gz → 0.0.25__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.
Files changed (43) hide show
  1. {dirsql-0.0.23 → dirsql-0.0.25}/Cargo.lock +8 -20
  2. dirsql-0.0.25/Cargo.toml +18 -0
  3. {dirsql-0.0.23 → dirsql-0.0.25}/PKG-INFO +1 -1
  4. {dirsql-0.0.23 → dirsql-0.0.25}/packages/python/Cargo.toml +6 -5
  5. dirsql-0.0.25/packages/python/src/lib.rs +315 -0
  6. {dirsql-0.0.23 → dirsql-0.0.25}/packages/python/tests/integration/test_async_dirsql.py +13 -7
  7. dirsql-0.0.25/packages/python/tests/integration/test_binding.py +266 -0
  8. dirsql-0.0.25/packages/python/tests/integration/test_docs_gaps.py +374 -0
  9. {dirsql-0.0.23/packages/core → dirsql-0.0.25/packages/rust}/Cargo.toml +14 -9
  10. dirsql-0.0.25/packages/rust/README.md +22 -0
  11. {dirsql-0.0.23/packages/core → dirsql-0.0.25/packages/rust}/benches/db_bench.rs +1 -1
  12. {dirsql-0.0.23/packages/core → dirsql-0.0.25/packages/rust}/benches/differ_bench.rs +2 -2
  13. {dirsql-0.0.23/packages/core → dirsql-0.0.25/packages/rust}/benches/matcher_bench.rs +1 -1
  14. {dirsql-0.0.23/packages/core → dirsql-0.0.25/packages/rust}/benches/scanner_bench.rs +2 -2
  15. {dirsql-0.0.23/packages/core → dirsql-0.0.25/packages/rust}/src/differ.rs +71 -31
  16. dirsql-0.0.25/packages/rust/src/lib.rs +713 -0
  17. dirsql-0.0.25/packages/rust/tests/async_sdk.rs +174 -0
  18. dirsql-0.0.25/packages/rust/tests/docs_examples.rs +812 -0
  19. dirsql-0.0.25/packages/rust/tests/docs_gaps.rs +327 -0
  20. dirsql-0.0.25/packages/rust/tests/from_config.rs +256 -0
  21. dirsql-0.0.25/packages/rust/tests/sdk.rs +390 -0
  22. {dirsql-0.0.23 → dirsql-0.0.25}/pyproject.toml +1 -1
  23. dirsql-0.0.23/Cargo.toml +0 -3
  24. dirsql-0.0.23/packages/core/README.md +0 -29
  25. dirsql-0.0.23/packages/core/src/lib.rs +0 -7
  26. dirsql-0.0.23/packages/python/src/lib.rs +0 -848
  27. {dirsql-0.0.23 → dirsql-0.0.25}/README.md +0 -0
  28. {dirsql-0.0.23 → dirsql-0.0.25}/packages/python/README.md +0 -0
  29. {dirsql-0.0.23 → dirsql-0.0.25}/packages/python/tests/__init__.py +0 -0
  30. {dirsql-0.0.23 → dirsql-0.0.25}/packages/python/tests/conftest.py +0 -0
  31. {dirsql-0.0.23 → dirsql-0.0.25}/packages/python/tests/integration/__init__.py +0 -0
  32. {dirsql-0.0.23 → dirsql-0.0.25}/packages/python/tests/integration/test_dirsql.py +0 -0
  33. {dirsql-0.0.23 → dirsql-0.0.25}/packages/python/tests/integration/test_docs_examples.py +0 -0
  34. {dirsql-0.0.23 → dirsql-0.0.25}/packages/python/tests/integration/test_from_config.py +0 -0
  35. {dirsql-0.0.23/packages/core → dirsql-0.0.25/packages/rust}/src/config.rs +0 -0
  36. {dirsql-0.0.23/packages/core → dirsql-0.0.25/packages/rust}/src/db.rs +0 -0
  37. {dirsql-0.0.23/packages/core → dirsql-0.0.25/packages/rust}/src/matcher.rs +0 -0
  38. {dirsql-0.0.23/packages/core → dirsql-0.0.25/packages/rust}/src/parser.rs +0 -0
  39. {dirsql-0.0.23/packages/core → dirsql-0.0.25/packages/rust}/src/scanner.rs +0 -0
  40. {dirsql-0.0.23/packages/core → dirsql-0.0.25/packages/rust}/src/watcher.rs +0 -0
  41. {dirsql-0.0.23 → dirsql-0.0.25}/python/dirsql/__init__.py +0 -0
  42. {dirsql-0.0.23 → dirsql-0.0.25}/python/dirsql/_async.py +0 -0
  43. {dirsql-0.0.23 → dirsql-0.0.25}/python/dirsql/test_async.py +0 -0
@@ -253,18 +253,12 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
253
253
  [[package]]
254
254
  name = "dirsql"
255
255
  version = "0.1.0"
256
- dependencies = [
257
- "dirsql-core",
258
- "pyo3",
259
- "regex",
260
- ]
261
-
262
- [[package]]
263
- name = "dirsql-core"
264
- version = "0.1.0"
265
256
  dependencies = [
266
257
  "criterion",
267
258
  "csv",
259
+ "futures-channel",
260
+ "futures-executor",
261
+ "futures-util",
268
262
  "globset",
269
263
  "notify",
270
264
  "regex",
@@ -274,6 +268,7 @@ dependencies = [
274
268
  "serde_yaml",
275
269
  "tempfile",
276
270
  "thiserror",
271
+ "tokio",
277
272
  "toml",
278
273
  "walkdir",
279
274
  ]
@@ -282,7 +277,7 @@ dependencies = [
282
277
  name = "dirsql-napi"
283
278
  version = "0.1.0"
284
279
  dependencies = [
285
- "dirsql-core",
280
+ "dirsql",
286
281
  "napi",
287
282
  "napi-build",
288
283
  "napi-derive",
@@ -290,18 +285,11 @@ dependencies = [
290
285
  ]
291
286
 
292
287
  [[package]]
293
- name = "dirsql-sdk"
288
+ name = "dirsql-py-ext"
294
289
  version = "0.1.0"
295
290
  dependencies = [
296
- "dirsql-core",
297
- "futures-channel",
298
- "futures-core",
299
- "futures-executor",
300
- "futures-util",
301
- "serde_json",
302
- "tempfile",
303
- "thiserror",
304
- "tokio",
291
+ "dirsql",
292
+ "pyo3",
305
293
  ]
306
294
 
307
295
  [[package]]
@@ -0,0 +1,18 @@
1
+ [workspace]
2
+ members = ["packages/python", "packages/rust"]
3
+ resolver = "2"
4
+
5
+ [workspace.package]
6
+ version = "0.1.0"
7
+ edition = "2024"
8
+ license = "MIT"
9
+ repository = "https://github.com/thekevinscott/dirsql"
10
+
11
+ [workspace.dependencies]
12
+ dirsql = { path = "packages/rust" }
13
+ serde = { version = "1", features = ["derive"] }
14
+ serde_json = "1"
15
+ regex = "1"
16
+ thiserror = "2"
17
+ tokio = { version = "1", features = ["rt", "sync"] }
18
+ futures-channel = "0.3"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirsql
3
- Version: 0.0.23
3
+ Version: 0.0.25
4
4
  Requires-Dist: pytest>=8 ; extra == 'dev'
5
5
  Requires-Dist: pytest-describe>=2 ; extra == 'dev'
6
6
  Requires-Dist: pytest-asyncio>=0.23 ; extra == 'dev'
@@ -1,16 +1,17 @@
1
1
  [package]
2
- name = "dirsql"
3
- version = "0.1.0"
4
- edition = "2024"
2
+ name = "dirsql-py-ext"
3
+ version.workspace = true
4
+ edition.workspace = true
5
+ publish = false
5
6
  readme = "README.md"
6
7
 
7
8
  [lib]
9
+ name = "dirsql_py_ext"
8
10
  crate-type = ["cdylib", "rlib"]
9
11
 
10
12
  [dependencies]
11
- dirsql-core = { path = "../core" }
13
+ dirsql.workspace = true
12
14
  pyo3 = { version = "0.28", features = ["extension-module"], optional = true }
13
- regex = "1"
14
15
 
15
16
  [features]
16
17
  default = []
@@ -0,0 +1,315 @@
1
+ //! PyO3 binding for `dirsql`. Intentionally thin: all orchestration lives in
2
+ //! `dirsql::DirSQL`. This layer only:
3
+ //!
4
+ //! - wraps a Python `extract` callable in a Rust closure (acquiring the GIL as
5
+ //! needed) so it can be handed to [`dirsql::Table`]
6
+ //! - converts row dicts, values, and events between Python and Rust
7
+ //! - forwards `new` / `from_config` / `query` / `_start_watcher` /
8
+ //! `_poll_events` to the corresponding `DirSQL` methods
9
+ //!
10
+ //! The Python-side async wrapper (`dirsql._async.DirSQL`) drives this binding
11
+ //! via `asyncio.to_thread`.
12
+
13
+ #[cfg(feature = "extension-module")]
14
+ mod python {
15
+ use ::dirsql::{DirSQL, Row, RowEvent, Table, Value};
16
+ use pyo3::exceptions::PyRuntimeError;
17
+ use pyo3::prelude::*;
18
+ use pyo3::types::{PyDict, PyList};
19
+ use std::collections::HashMap;
20
+ use std::time::Duration;
21
+
22
+ // -- Public PyO3 classes ------------------------------------------------
23
+
24
+ /// A table definition. Mirrors `dirsql::Table` but holds a Python
25
+ /// callable for `extract`.
26
+ #[pyclass(name = "Table", frozen)]
27
+ struct PyTable {
28
+ #[pyo3(get)]
29
+ ddl: String,
30
+ #[pyo3(get)]
31
+ glob: String,
32
+ extract: Py<PyAny>,
33
+ #[pyo3(get)]
34
+ strict: bool,
35
+ }
36
+
37
+ #[pymethods]
38
+ impl PyTable {
39
+ #[new]
40
+ #[pyo3(signature = (*, ddl, glob, extract, strict=false))]
41
+ fn new(ddl: String, glob: String, extract: Py<PyAny>, strict: bool) -> Self {
42
+ PyTable {
43
+ ddl,
44
+ glob,
45
+ extract,
46
+ strict,
47
+ }
48
+ }
49
+ }
50
+
51
+ /// A row event produced by the watch loop.
52
+ #[pyclass(name = "RowEvent", frozen)]
53
+ struct PyRowEvent {
54
+ #[pyo3(get)]
55
+ table: String,
56
+ #[pyo3(get)]
57
+ action: String,
58
+ #[pyo3(get)]
59
+ row: Option<Py<PyDict>>,
60
+ #[pyo3(get)]
61
+ old_row: Option<Py<PyDict>>,
62
+ #[pyo3(get)]
63
+ error: Option<String>,
64
+ #[pyo3(get)]
65
+ file_path: Option<String>,
66
+ }
67
+
68
+ #[pymethods]
69
+ impl PyRowEvent {
70
+ fn __repr__(&self) -> String {
71
+ format!("RowEvent(table={:?}, action={:?})", self.table, self.action)
72
+ }
73
+ }
74
+
75
+ /// Synchronous binding class. `dirsql._async.DirSQL` wraps it with
76
+ /// `asyncio.to_thread` to produce the async public API.
77
+ #[pyclass(name = "DirSQL")]
78
+ struct PyDirSQL {
79
+ inner: DirSQL,
80
+ }
81
+
82
+ #[pymethods]
83
+ impl PyDirSQL {
84
+ #[new]
85
+ #[pyo3(signature = (root, *, tables, ignore=None))]
86
+ fn new(
87
+ py: Python<'_>,
88
+ root: String,
89
+ tables: Vec<PyRef<'_, PyTable>>,
90
+ ignore: Option<Vec<String>>,
91
+ ) -> PyResult<Self> {
92
+ let rust_tables: Vec<Table> = tables.iter().map(|t| build_table(py, t)).collect();
93
+
94
+ let inner = py
95
+ .detach(move || match ignore {
96
+ Some(ig) => DirSQL::with_ignore(root, rust_tables, ig),
97
+ None => DirSQL::new(root, rust_tables),
98
+ })
99
+ .map_err(to_py_err)?;
100
+
101
+ Ok(Self { inner })
102
+ }
103
+
104
+ #[classmethod]
105
+ fn from_config(
106
+ _cls: &Bound<'_, pyo3::types::PyType>,
107
+ py: Python<'_>,
108
+ path: String,
109
+ ) -> PyResult<Self> {
110
+ let inner = py
111
+ .detach(move || DirSQL::from_config_path(&path))
112
+ .map_err(to_py_err)?;
113
+ Ok(Self { inner })
114
+ }
115
+
116
+ fn query(&self, py: Python<'_>, sql: String) -> PyResult<Py<PyList>> {
117
+ let db = self.inner.clone();
118
+ let rows = py.detach(move || db.query(&sql)).map_err(to_py_err)?;
119
+
120
+ let list = PyList::empty(py);
121
+ for row in rows {
122
+ list.append(value_row_to_py_dict(py, &row)?)?;
123
+ }
124
+ Ok(list.unbind())
125
+ }
126
+
127
+ fn _start_watcher(&self, py: Python<'_>) -> PyResult<()> {
128
+ let db = self.inner.clone();
129
+ py.detach(move || db.start_watching()).map_err(to_py_err)
130
+ }
131
+
132
+ fn _poll_events(&self, py: Python<'_>, timeout_ms: u64) -> PyResult<Vec<PyRowEvent>> {
133
+ let db = self.inner.clone();
134
+ let events = py
135
+ .detach(move || db.poll_events(Duration::from_millis(timeout_ms)))
136
+ .map_err(to_py_err)?;
137
+
138
+ events.iter().map(|e| row_event_to_py(py, e)).collect()
139
+ }
140
+ }
141
+
142
+ // -- Helpers ------------------------------------------------------------
143
+
144
+ fn build_table(py: Python<'_>, t: &PyTable) -> Table {
145
+ let extract_ref = t.extract.clone_ref(py);
146
+ let mut table = Table::try_new(
147
+ t.ddl.clone(),
148
+ t.glob.clone(),
149
+ make_extract_closure(extract_ref),
150
+ );
151
+ table.strict = t.strict;
152
+ table
153
+ }
154
+
155
+ type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
156
+
157
+ fn make_extract_closure(
158
+ extract: Py<PyAny>,
159
+ ) -> impl Fn(&str, &str) -> std::result::Result<Vec<Row>, BoxError> + Send + Sync + 'static
160
+ {
161
+ move |path: &str, content: &str| {
162
+ Python::attach(|py| -> std::result::Result<Vec<Row>, BoxError> {
163
+ let result = extract
164
+ .call1(py, (path, content))
165
+ .map_err(|e| -> BoxError { Box::new(ExtractError(e.to_string())) })?;
166
+ let raw: Vec<HashMap<String, Py<PyAny>>> = result
167
+ .extract(py)
168
+ .map_err(|e: PyErr| -> BoxError { Box::new(ExtractError(e.to_string())) })?;
169
+
170
+ let mut rows = Vec::with_capacity(raw.len());
171
+ for r in &raw {
172
+ rows.push(
173
+ convert_py_row(py, r)
174
+ .map_err(|e| -> BoxError { Box::new(ExtractError(e.to_string())) })?,
175
+ );
176
+ }
177
+ Ok(rows)
178
+ })
179
+ }
180
+ }
181
+
182
+ #[derive(Debug)]
183
+ struct ExtractError(String);
184
+ impl std::fmt::Display for ExtractError {
185
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186
+ f.write_str(&self.0)
187
+ }
188
+ }
189
+ impl std::error::Error for ExtractError {}
190
+
191
+ fn to_py_err<E: std::fmt::Display>(e: E) -> PyErr {
192
+ PyRuntimeError::new_err(e.to_string())
193
+ }
194
+
195
+ fn row_event_to_py(py: Python<'_>, event: &RowEvent) -> PyResult<PyRowEvent> {
196
+ Ok(match event {
197
+ RowEvent::Insert {
198
+ table,
199
+ row,
200
+ file_path,
201
+ } => PyRowEvent {
202
+ table: table.clone(),
203
+ action: "insert".to_string(),
204
+ row: Some(value_row_to_py_dict(py, row)?),
205
+ old_row: None,
206
+ error: None,
207
+ file_path: Some(file_path.clone()),
208
+ },
209
+ RowEvent::Update {
210
+ table,
211
+ old_row,
212
+ new_row,
213
+ file_path,
214
+ } => PyRowEvent {
215
+ table: table.clone(),
216
+ action: "update".to_string(),
217
+ row: Some(value_row_to_py_dict(py, new_row)?),
218
+ old_row: Some(value_row_to_py_dict(py, old_row)?),
219
+ error: None,
220
+ file_path: Some(file_path.clone()),
221
+ },
222
+ RowEvent::Delete {
223
+ table,
224
+ row,
225
+ file_path,
226
+ } => PyRowEvent {
227
+ table: table.clone(),
228
+ action: "delete".to_string(),
229
+ row: Some(value_row_to_py_dict(py, row)?),
230
+ old_row: None,
231
+ error: None,
232
+ file_path: Some(file_path.clone()),
233
+ },
234
+ RowEvent::Error { file_path, error } => PyRowEvent {
235
+ table: String::new(),
236
+ action: "error".to_string(),
237
+ row: None,
238
+ old_row: None,
239
+ error: Some(error.clone()),
240
+ file_path: Some(file_path.to_string_lossy().to_string()),
241
+ },
242
+ })
243
+ }
244
+
245
+ fn value_row_to_py_dict(py: Python<'_>, row: &Row) -> PyResult<Py<PyDict>> {
246
+ let dict = PyDict::new(py);
247
+ for (key, value) in row {
248
+ dict.set_item(key, value_to_py(py, value))?;
249
+ }
250
+ Ok(dict.unbind())
251
+ }
252
+
253
+ fn convert_py_row(
254
+ py: Python<'_>,
255
+ py_row: &HashMap<String, Py<PyAny>>,
256
+ ) -> PyResult<HashMap<String, Value>> {
257
+ let mut row = HashMap::new();
258
+ for (key, val) in py_row {
259
+ row.insert(key.clone(), py_to_value(py, val)?);
260
+ }
261
+ Ok(row)
262
+ }
263
+
264
+ fn py_to_value(py: Python<'_>, obj: &Py<PyAny>) -> PyResult<Value> {
265
+ let bound = obj.bind(py);
266
+
267
+ if bound.is_none() {
268
+ return Ok(Value::Null);
269
+ }
270
+
271
+ // bool must precede int (bool is a subclass of int in Python).
272
+ if bound.is_instance_of::<pyo3::types::PyBool>() {
273
+ let b: bool = bound.extract()?;
274
+ return Ok(Value::Integer(if b { 1 } else { 0 }));
275
+ }
276
+
277
+ if let Ok(i) = bound.extract::<i64>() {
278
+ return Ok(Value::Integer(i));
279
+ }
280
+ if let Ok(f) = bound.extract::<f64>() {
281
+ return Ok(Value::Real(f));
282
+ }
283
+ if let Ok(s) = bound.extract::<String>() {
284
+ return Ok(Value::Text(s));
285
+ }
286
+ if let Ok(b) = bound.extract::<Vec<u8>>() {
287
+ return Ok(Value::Blob(b));
288
+ }
289
+
290
+ // Fall back to the Python repr.
291
+ Ok(Value::Text(bound.str()?.to_string()))
292
+ }
293
+
294
+ fn value_to_py(py: Python<'_>, value: &Value) -> Py<PyAny> {
295
+ match value {
296
+ Value::Null => py.None(),
297
+ Value::Integer(i) => i.into_pyobject(py).unwrap().into_any().unbind(),
298
+ Value::Real(f) => f.into_pyobject(py).unwrap().into_any().unbind(),
299
+ Value::Text(s) => s.into_pyobject(py).unwrap().into_any().unbind(),
300
+ Value::Blob(b) => b.into_pyobject(py).unwrap().unbind(),
301
+ }
302
+ }
303
+
304
+ // -- Module registration ------------------------------------------------
305
+
306
+ #[pymodule]
307
+ #[pyo3(name = "_dirsql")]
308
+ fn py_dirsql_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
309
+ m.add("__version__", env!("CARGO_PKG_VERSION"))?;
310
+ m.add_class::<PyTable>()?;
311
+ m.add_class::<PyDirSQL>()?;
312
+ m.add_class::<PyRowEvent>()?;
313
+ Ok(())
314
+ }
315
+ }
@@ -180,7 +180,7 @@ def describe_DirSQL_async():
180
180
  async def collect_events():
181
181
  async for event in db.watch():
182
182
  events.append(event)
183
- if len(events) >= 1:
183
+ if event.action == "insert":
184
184
  break
185
185
 
186
186
  task = asyncio.create_task(collect_events())
@@ -188,9 +188,15 @@ def describe_DirSQL_async():
188
188
  # Give the watcher time to start
189
189
  await asyncio.sleep(0.3)
190
190
 
191
- # Create a new file
192
- with open(os.path.join(tmp_dir, "new_item.json"), "w") as f:
191
+ # Create a new file atomically -- write to a sibling tmp path
192
+ # then rename into place. Without this the watcher can fire on
193
+ # the empty file between open() and write, producing a spurious
194
+ # error event ahead of the insert.
195
+ final = os.path.join(tmp_dir, "new_item.json")
196
+ tmp = final + ".tmp"
197
+ with open(tmp, "w") as f:
193
198
  json.dump({"name": "apple"}, f)
199
+ os.replace(tmp, final)
194
200
 
195
201
  # Wait for events with timeout
196
202
  try:
@@ -198,10 +204,10 @@ def describe_DirSQL_async():
198
204
  except asyncio.TimeoutError:
199
205
  pytest.fail("Timed out waiting for watch events")
200
206
 
201
- assert len(events) >= 1
202
- assert events[0].action == "insert"
203
- assert events[0].table == "items"
204
- assert events[0].row["name"] == "apple"
207
+ insert = next((e for e in events if e.action == "insert"), None)
208
+ assert insert is not None, f"no insert event in {events!r}"
209
+ assert insert.table == "items"
210
+ assert insert.row["name"] == "apple"
205
211
 
206
212
  @pytest.mark.asyncio
207
213
  async def it_emits_delete_events_for_removed_files(tmp_dir):