dirsql 0.3.15__tar.gz → 0.3.17__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 (156) hide show
  1. {dirsql-0.3.15 → dirsql-0.3.17}/Cargo.lock +1 -1
  2. {dirsql-0.3.15 → dirsql-0.3.17}/PKG-INFO +2 -1
  3. {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/_async.py +2 -2
  4. dirsql-0.3.17/dirsql/_dirsql.pyi +63 -0
  5. dirsql-0.3.17/dirsql/cli/interpret/__init__.py +17 -0
  6. dirsql-0.3.17/dirsql/cli/interpret/dispatch_extract.py +41 -0
  7. dirsql-0.3.17/dirsql/cli/interpret/dispatch_extract_test.py +92 -0
  8. dirsql-0.3.17/dirsql/cli/interpret/load_app.py +32 -0
  9. dirsql-0.3.17/dirsql/cli/interpret/load_app_test.py +85 -0
  10. dirsql-0.3.17/dirsql/cli/interpret/run.py +66 -0
  11. dirsql-0.3.17/dirsql/cli/interpret/run_test.py +172 -0
  12. dirsql-0.3.17/dirsql/cli/interpret/write_message.py +18 -0
  13. dirsql-0.3.17/dirsql/cli/interpret/write_message_test.py +30 -0
  14. {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/cli/main.py +15 -3
  15. {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/cli/main_test.py +45 -2
  16. {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/resolve_config.py +1 -1
  17. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/Cargo.toml +1 -1
  18. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/src/lib.rs +15 -1
  19. dirsql-0.3.17/packages/python/tests/e2e/__init__.py +0 -0
  20. dirsql-0.3.17/packages/python/tests/integration/__fixtures__/interpret/data/a/meta.json +1 -0
  21. dirsql-0.3.17/packages/python/tests/integration/__fixtures__/interpret/data/b/meta.json +1 -0
  22. dirsql-0.3.17/packages/python/tests/integration/__fixtures__/interpret/dirsql.config.py +27 -0
  23. dirsql-0.3.17/packages/python/tests/integration/__fixtures__/interpret/dirsql.config_no_app.py +3 -0
  24. dirsql-0.3.17/packages/python/tests/integration/__fixtures__/interpret/dirsql.config_raises.py +21 -0
  25. dirsql-0.3.17/packages/python/tests/integration/interpret_subprocess.py +96 -0
  26. dirsql-0.3.17/packages/python/tests/integration/test_interpret.py +145 -0
  27. {dirsql-0.3.15 → dirsql-0.3.17}/pyproject.toml +32 -5
  28. {dirsql-0.3.15 → dirsql-0.3.17}/Cargo.toml +0 -0
  29. {dirsql-0.3.15 → dirsql-0.3.17}/README.md +0 -0
  30. {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/__init__.py +0 -0
  31. {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/cli/__init__.py +0 -0
  32. {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/cli/binary_path.py +0 -0
  33. {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/cli/binary_path_test.py +0 -0
  34. {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/cli/is_windows.py +0 -0
  35. {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/cli/is_windows_test.py +0 -0
  36. /dirsql-0.3.15/packages/python/tests/e2e/__init__.py → /dirsql-0.3.17/dirsql/py.typed +0 -0
  37. {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/resolve_config_test.py +0 -0
  38. {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/test_async.py +0 -0
  39. {dirsql-0.3.15 → dirsql-0.3.17}/docs/.claude/CLAUDE.md +0 -0
  40. {dirsql-0.3.15 → dirsql-0.3.17}/docs/.vitepress/config.ts +0 -0
  41. {dirsql-0.3.15 → dirsql-0.3.17}/docs/.vitepress/theme/index.ts +0 -0
  42. {dirsql-0.3.15 → dirsql-0.3.17}/docs/.vitepress/theme/lang.ts +0 -0
  43. {dirsql-0.3.15 → dirsql-0.3.17}/docs/AGENTS.md +0 -0
  44. {dirsql-0.3.15 → dirsql-0.3.17}/docs/api/index.md +0 -0
  45. {dirsql-0.3.15 → dirsql-0.3.17}/docs/cli/config.md +0 -0
  46. {dirsql-0.3.15 → dirsql-0.3.17}/docs/cli/http-api.md +0 -0
  47. {dirsql-0.3.15 → dirsql-0.3.17}/docs/cli/index.md +0 -0
  48. {dirsql-0.3.15 → dirsql-0.3.17}/docs/cli/init.md +0 -0
  49. {dirsql-0.3.15 → dirsql-0.3.17}/docs/cli/server.md +0 -0
  50. {dirsql-0.3.15 → dirsql-0.3.17}/docs/getting-started.md +0 -0
  51. {dirsql-0.3.15 → dirsql-0.3.17}/docs/guide/async.md +0 -0
  52. {dirsql-0.3.15 → dirsql-0.3.17}/docs/guide/crdt.md +0 -0
  53. {dirsql-0.3.15 → dirsql-0.3.17}/docs/guide/persistence.md +0 -0
  54. {dirsql-0.3.15 → dirsql-0.3.17}/docs/guide/querying.md +0 -0
  55. {dirsql-0.3.15 → dirsql-0.3.17}/docs/guide/tables.md +0 -0
  56. {dirsql-0.3.15 → dirsql-0.3.17}/docs/guide/watching.md +0 -0
  57. {dirsql-0.3.15 → dirsql-0.3.17}/docs/index.md +0 -0
  58. {dirsql-0.3.15 → dirsql-0.3.17}/docs/migrations.md +0 -0
  59. {dirsql-0.3.15 → dirsql-0.3.17}/docs/package.json +0 -0
  60. {dirsql-0.3.15 → dirsql-0.3.17}/docs/playwright.config.ts +0 -0
  61. {dirsql-0.3.15 → dirsql-0.3.17}/docs/pnpm-lock.yaml +0 -0
  62. {dirsql-0.3.15 → dirsql-0.3.17}/docs/pnpm-workspace.yaml +0 -0
  63. {dirsql-0.3.15 → dirsql-0.3.17}/docs/tests/integration/home.spec.ts +0 -0
  64. {dirsql-0.3.15 → dirsql-0.3.17}/docs/tests/integration/language-flag.spec.ts +0 -0
  65. {dirsql-0.3.15 → dirsql-0.3.17}/docs/tests/unit/config.test.ts +0 -0
  66. {dirsql-0.3.15 → dirsql-0.3.17}/docs/tests/unit/lang.test.ts +0 -0
  67. {dirsql-0.3.15 → dirsql-0.3.17}/docs/vitest.config.ts +0 -0
  68. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/README.md +0 -0
  69. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/conftest.py +0 -0
  70. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/.claude/CLAUDE.md +0 -0
  71. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/.vitepress/config.ts +0 -0
  72. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/.vitepress/theme/index.ts +0 -0
  73. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/.vitepress/theme/lang.ts +0 -0
  74. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/AGENTS.md +0 -0
  75. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/api/index.md +0 -0
  76. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/cli/config.md +0 -0
  77. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/cli/http-api.md +0 -0
  78. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/cli/index.md +0 -0
  79. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/cli/init.md +0 -0
  80. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/cli/server.md +0 -0
  81. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/getting-started.md +0 -0
  82. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/guide/async.md +0 -0
  83. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/guide/crdt.md +0 -0
  84. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/guide/persistence.md +0 -0
  85. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/guide/querying.md +0 -0
  86. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/guide/tables.md +0 -0
  87. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/guide/watching.md +0 -0
  88. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/index.md +0 -0
  89. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/migrations.md +0 -0
  90. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/package.json +0 -0
  91. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/playwright.config.ts +0 -0
  92. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/pnpm-lock.yaml +0 -0
  93. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/pnpm-workspace.yaml +0 -0
  94. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/tests/integration/home.spec.ts +0 -0
  95. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/tests/integration/language-flag.spec.ts +0 -0
  96. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/tests/unit/config.test.ts +0 -0
  97. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/tests/unit/lang.test.ts +0 -0
  98. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/vitest.config.ts +0 -0
  99. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/tests/__init__.py +0 -0
  100. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/tests/conftest.py +0 -0
  101. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/tests/integration/__init__.py +0 -0
  102. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/tests/integration/test_async_dirsql.py +0 -0
  103. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/tests/integration/test_binding.py +0 -0
  104. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/tests/integration/test_dirsql.py +0 -0
  105. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/tests/integration/test_docs_examples.py +0 -0
  106. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/tests/integration/test_docs_gaps.py +0 -0
  107. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/tests/integration/test_from_config.py +0 -0
  108. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/tests/integration/test_persist.py +0 -0
  109. {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/tests/integration/test_serialization.py +0 -0
  110. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/Cargo.toml +0 -0
  111. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/README.md +0 -0
  112. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/benches/db_bench.rs +0 -0
  113. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/benches/differ_bench.rs +0 -0
  114. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/benches/matcher_bench.rs +0 -0
  115. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/benches/scanner_bench.rs +0 -0
  116. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/api/index.md +0 -0
  117. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/cli/config.md +0 -0
  118. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/cli/http-api.md +0 -0
  119. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/cli/index.md +0 -0
  120. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/cli/init.md +0 -0
  121. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/cli/server.md +0 -0
  122. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/getting-started.md +0 -0
  123. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/guide/async.md +0 -0
  124. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/guide/crdt.md +0 -0
  125. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/guide/persistence.md +0 -0
  126. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/guide/querying.md +0 -0
  127. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/guide/tables.md +0 -0
  128. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/guide/watching.md +0 -0
  129. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/index.md +0 -0
  130. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/migrations.md +0 -0
  131. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/bin/dirsql.rs +0 -0
  132. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/cli/init.rs +0 -0
  133. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/cli/mod.rs +0 -0
  134. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/cli/router.rs +0 -0
  135. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/cli/serialize.rs +0 -0
  136. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/cli/server.rs +0 -0
  137. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/config.rs +0 -0
  138. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/db.rs +0 -0
  139. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/differ.rs +0 -0
  140. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/lib.rs +0 -0
  141. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/matcher.rs +0 -0
  142. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/persist.rs +0 -0
  143. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/scanner.rs +0 -0
  144. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/watcher.rs +0 -0
  145. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/async_sdk.rs +0 -0
  146. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/cli_e2e.rs +0 -0
  147. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/cli_integration.rs +0 -0
  148. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/docs_examples.rs +0 -0
  149. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/docs_gaps.rs +0 -0
  150. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/from_config.rs +0 -0
  151. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/init_e2e.rs +0 -0
  152. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/init_integration.rs +0 -0
  153. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/persist.rs +0 -0
  154. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/readonly_query.rs +0 -0
  155. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/sdk.rs +0 -0
  156. {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/serialization.rs +0 -0
@@ -499,7 +499,7 @@ dependencies = [
499
499
 
500
500
  [[package]]
501
501
  name = "dirsql-py-ext"
502
- version = "0.3.15"
502
+ version = "0.3.17"
503
503
  dependencies = [
504
504
  "dirsql",
505
505
  "pyo3",
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirsql
3
- Version: 0.3.15
3
+ Version: 0.3.17
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'
7
7
  Requires-Dist: pytest-cov>=5 ; extra == 'dev'
8
8
  Requires-Dist: ruff>=0.4 ; extra == 'dev'
9
9
  Requires-Dist: maturin>=1.0 ; extra == 'dev'
10
+ Requires-Dist: ty==0.0.42 ; extra == 'dev'
10
11
  Provides-Extra: dev
11
12
  Summary: Ephemeral SQL index over a local directory
12
13
  Keywords: sql,filesystem,directory,sqlite,index
@@ -104,14 +104,14 @@ class DirSQL:
104
104
 
105
105
  async def query(self, sql):
106
106
  """Execute a SQL query asynchronously."""
107
- return await asyncio.to_thread(self._db.query, sql)
107
+ return await asyncio.to_thread(self._db.query, sql) # ty:ignore[unresolved-attribute]
108
108
 
109
109
  def watch(self):
110
110
  """Start watching for file changes. Returns an async iterable of RowEvent."""
111
111
  return _WatchStream(self._db)
112
112
 
113
113
  @property
114
- def __dict__(self):
114
+ def __dict__(self): # ty:ignore[missing-override-decorator]
115
115
  """Resolved construction state as a JSON-serializable dict.
116
116
 
117
117
  Recomputed on each access; reads the ``.dirsql.toml`` if ``config=``
@@ -0,0 +1,63 @@
1
+ """Type stubs for the native PyO3 extension module.
2
+
3
+ Mirrors the surface defined in ``packages/python/src/lib.rs``. Hand-written
4
+ because pyo3-stub-gen would otherwise demand a build-time hook that the
5
+ maturin / putitoutthere release pipeline does not yet run.
6
+
7
+ Whenever ``src/lib.rs`` adds, renames, or removes a ``#[pyclass]``,
8
+ ``#[pymethods]``, or module-level binding, this file MUST be updated in the
9
+ same PR -- and ``PARITY.md`` is the canonical reminder.
10
+ """
11
+
12
+ from collections.abc import Callable
13
+ from os import PathLike
14
+ from typing import Any
15
+
16
+ __version__: str
17
+
18
+ Row = dict[str, Any]
19
+
20
+ class Table:
21
+ """A table definition. Construct via keyword arguments only."""
22
+
23
+ ddl: str
24
+ glob: str
25
+ strict: bool
26
+
27
+ def __init__(
28
+ self,
29
+ *,
30
+ ddl: str,
31
+ glob: str,
32
+ extract: Callable[[str], list[Row]],
33
+ strict: bool = False,
34
+ ) -> None: ...
35
+
36
+ class RowEvent:
37
+ """A row event produced by the watch loop."""
38
+
39
+ table: str | None
40
+ action: str
41
+ row: Row | None
42
+ old_row: Row | None
43
+ error: str | None
44
+ file_path: str | None
45
+
46
+ def __repr__(self) -> str: ... # ty:ignore[missing-override-decorator]
47
+
48
+ class DirSQL:
49
+ """Synchronous binding class. ``dirsql._async.DirSQL`` wraps it."""
50
+
51
+ def __init__(
52
+ self,
53
+ root: str | None = None,
54
+ *,
55
+ tables: list[Table] | None = None,
56
+ ignore: list[str] | None = None,
57
+ config: str | None = None,
58
+ persist: bool = False,
59
+ persist_path: str | PathLike[str] | None = None,
60
+ ) -> None: ...
61
+ def query(self, sql: str) -> list[Row]: ...
62
+ def _start_watcher(self) -> None: ...
63
+ def _poll_events(self, timeout_ms: int) -> list[RowEvent]: ...
@@ -0,0 +1,17 @@
1
+ """`dirsql interpret <config>` -- long-running native config helper (#196).
2
+
3
+ Loads a Python config file, takes its module-level ``app = DirSQL(...)``,
4
+ and serves ``extract`` requests over NDJSON on stdin/stdout. One line in,
5
+ one line out, sequential.
6
+
7
+ See the individual submodules for behavior:
8
+ - ``run`` -- subcommand entry point invoked from ``dirsql.cli.main``
9
+ - ``load_app`` -- importlib-based config loader
10
+ - ``write_message`` -- single-line NDJSON writer
11
+ - ``dispatch_extract`` -- per-request handler
12
+
13
+ No top-level re-exports: the function is at ``.run.run``. Re-exporting
14
+ ``run`` at the package level would shadow the submodule (so
15
+ ``from . import run`` in tests would resolve to the function and break
16
+ ``patch.object(run_module, "load_app", ...)``).
17
+ """
@@ -0,0 +1,41 @@
1
+ """Single-request handler for the `dirsql interpret` helper.
2
+
3
+ Given a parsed request line and a name->table lookup, call the user's
4
+ ``extract`` callback and return the NDJSON response payload the loop
5
+ will write back to stdout.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+
13
+ def dispatch_extract(
14
+ req: dict[str, Any],
15
+ tables: dict[str, Any],
16
+ ) -> dict[str, Any]:
17
+ """Build the `{"type": "result", ...}` response for one extract request.
18
+
19
+ - Unknown table name -> ``ok: false`` with a name-bearing error.
20
+ - User's ``extract`` raising -> ``ok: false`` with ``str(exc)``.
21
+ - Success -> ``ok: true`` with the row list the callback returned.
22
+
23
+ ``req["id"]`` is echoed back unchanged so the caller can correlate
24
+ request and response.
25
+ """
26
+ rid = req.get("id")
27
+ name = req.get("table")
28
+ path = req.get("path")
29
+ table = tables.get(name) if isinstance(name, str) else None
30
+ if table is None:
31
+ return {
32
+ "type": "result",
33
+ "id": rid,
34
+ "ok": False,
35
+ "error": f"unknown table: {name!r}",
36
+ }
37
+ try:
38
+ rows = table.extract(path)
39
+ except Exception as exc:
40
+ return {"type": "result", "id": rid, "ok": False, "error": str(exc)}
41
+ return {"type": "result", "id": rid, "ok": True, "rows": rows}
@@ -0,0 +1,92 @@
1
+ """Unit tests for `dispatch_extract`."""
2
+
3
+ from types import SimpleNamespace
4
+
5
+ from .dispatch_extract import dispatch_extract
6
+
7
+
8
+ def _table(extract):
9
+ return SimpleNamespace(extract=extract)
10
+
11
+
12
+ def describe_dispatch_extract():
13
+ def it_returns_ok_true_with_the_rows_extract_produced():
14
+ tables = {"papers": _table(lambda p: [{"title": p}])}
15
+ out = dispatch_extract(
16
+ {"type": "extract", "id": 1, "table": "papers", "path": "/x.json"},
17
+ tables,
18
+ )
19
+ assert out == {
20
+ "type": "result",
21
+ "id": 1,
22
+ "ok": True,
23
+ "rows": [{"title": "/x.json"}],
24
+ }
25
+
26
+ def it_echoes_the_request_id_verbatim_on_success():
27
+ tables = {"papers": _table(lambda _p: [])}
28
+ out = dispatch_extract(
29
+ {"type": "extract", "id": 99, "table": "papers", "path": "/x.json"},
30
+ tables,
31
+ )
32
+ assert out["id"] == 99
33
+
34
+ def it_returns_ok_false_with_the_table_name_when_the_table_is_unknown():
35
+ out = dispatch_extract(
36
+ {"type": "extract", "id": 5, "table": "ghost", "path": "/x"},
37
+ tables={"papers": _table(lambda _p: [])},
38
+ )
39
+ assert out["type"] == "result"
40
+ assert out["id"] == 5
41
+ assert out["ok"] is False
42
+ assert "ghost" in out["error"]
43
+
44
+ def it_echoes_the_request_id_verbatim_on_unknown_table():
45
+ out = dispatch_extract(
46
+ {"type": "extract", "id": 17, "table": "ghost", "path": "/x"},
47
+ tables={},
48
+ )
49
+ assert out["id"] == 17
50
+
51
+ def it_returns_ok_false_with_the_exception_message_when_extract_raises():
52
+ def boom(_p):
53
+ raise ValueError("synthetic")
54
+
55
+ out = dispatch_extract(
56
+ {"type": "extract", "id": 7, "table": "papers", "path": "/x"},
57
+ {"papers": _table(boom)},
58
+ )
59
+ assert out == {
60
+ "type": "result",
61
+ "id": 7,
62
+ "ok": False,
63
+ "error": "synthetic",
64
+ }
65
+
66
+ def it_passes_the_request_path_to_the_extract_callback():
67
+ seen: list[str] = []
68
+
69
+ def capture(p):
70
+ seen.append(p)
71
+ return []
72
+
73
+ dispatch_extract(
74
+ {"type": "extract", "id": 1, "table": "t", "path": "/abs/x.json"},
75
+ {"t": _table(capture)},
76
+ )
77
+ assert seen == ["/abs/x.json"]
78
+
79
+ def it_treats_a_missing_table_field_as_unknown_table():
80
+ out = dispatch_extract(
81
+ {"type": "extract", "id": 1, "path": "/x"},
82
+ tables={"t": _table(lambda _p: [])},
83
+ )
84
+ assert out["ok"] is False
85
+ assert "None" in out["error"] or "none" in out["error"].lower()
86
+
87
+ def it_passes_through_a_missing_id_field_as_none():
88
+ out = dispatch_extract(
89
+ {"type": "extract", "table": "ghost", "path": "/x"},
90
+ tables={},
91
+ )
92
+ assert out["id"] is None
@@ -0,0 +1,32 @@
1
+ """importlib-based config loader for the `dirsql interpret` helper.
2
+
3
+ The user's config is a regular Python module that constructs a
4
+ ``DirSQL`` instance and assigns it to a top-level ``app`` name. We
5
+ import it via ``importlib.util.spec_from_file_location`` so the user
6
+ doesn't have to put their config on ``sys.path``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib.util
12
+ import os
13
+ from typing import Any
14
+
15
+
16
+ def load_app(config_path: str) -> Any:
17
+ """Import ``config_path`` and return its ``app`` attribute.
18
+
19
+ Raises ``ImportError`` if the file can't be loaded as a module, and
20
+ ``AttributeError`` if the module loads but defines no ``app``.
21
+ """
22
+ abs_path = os.path.abspath(config_path)
23
+ spec = importlib.util.spec_from_file_location("_dirsql_user_config", abs_path)
24
+ if spec is None or spec.loader is None:
25
+ raise ImportError(f"could not load config: {config_path}")
26
+ module = importlib.util.module_from_spec(spec)
27
+ spec.loader.exec_module(module)
28
+ if not hasattr(module, "app"):
29
+ raise AttributeError(
30
+ f"{config_path}: module must define a top-level `app = DirSQL(...)`"
31
+ )
32
+ return module.app
@@ -0,0 +1,85 @@
1
+ """Unit tests for `load_app`."""
2
+
3
+ import importlib.util
4
+ import os
5
+ from pathlib import Path
6
+ from unittest.mock import patch
7
+
8
+ import pytest
9
+
10
+ from . import load_app as load_app_mod
11
+ from .load_app import load_app
12
+
13
+
14
+ def describe_load_app():
15
+ def it_returns_the_app_attribute_of_the_loaded_module(tmp_path: Path):
16
+ config = tmp_path / "config.py"
17
+ config.write_text("app = {'sentinel': 'value'}\n")
18
+ result = load_app(str(config))
19
+ assert result == {"sentinel": "value"}
20
+
21
+ def it_resolves_relative_paths_against_the_cwd(tmp_path: Path):
22
+ config = tmp_path / "config.py"
23
+ config.write_text("app = 42\n")
24
+ prev = os.getcwd()
25
+ try:
26
+ os.chdir(tmp_path)
27
+ assert load_app("config.py") == 42
28
+ finally:
29
+ os.chdir(prev)
30
+
31
+ def it_raises_import_error_when_the_file_does_not_exist(tmp_path: Path):
32
+ missing = tmp_path / "nope.py"
33
+ with pytest.raises((ImportError, FileNotFoundError)):
34
+ load_app(str(missing))
35
+
36
+ def it_raises_attribute_error_with_a_path_aware_message_when_app_is_missing(
37
+ tmp_path: Path,
38
+ ):
39
+ config = tmp_path / "config.py"
40
+ config.write_text("not_the_app = 1\n")
41
+ with pytest.raises(AttributeError, match="must define a top-level `app"):
42
+ load_app(str(config))
43
+
44
+ def it_surfaces_exceptions_raised_during_module_exec(tmp_path: Path):
45
+ config = tmp_path / "config.py"
46
+ config.write_text("raise RuntimeError('synthetic boom')\n")
47
+ with pytest.raises(RuntimeError, match="synthetic boom"):
48
+ load_app(str(config))
49
+
50
+ def it_raises_import_error_when_spec_from_file_location_returns_none(
51
+ tmp_path: Path,
52
+ ):
53
+ with patch.object(
54
+ load_app_mod.importlib.util,
55
+ "spec_from_file_location",
56
+ return_value=None,
57
+ ):
58
+ with pytest.raises(ImportError, match="could not load config"):
59
+ load_app(str(tmp_path / "anything.py"))
60
+
61
+ def it_raises_import_error_when_spec_has_no_loader(tmp_path: Path):
62
+ class _SpecNoLoader:
63
+ loader = None
64
+
65
+ with patch.object(
66
+ load_app_mod.importlib.util,
67
+ "spec_from_file_location",
68
+ return_value=_SpecNoLoader(),
69
+ ):
70
+ with pytest.raises(ImportError, match="could not load config"):
71
+ load_app(str(tmp_path / "anything.py"))
72
+
73
+ def it_absolutizes_the_passed_path_before_loading(tmp_path: Path):
74
+ config = tmp_path / "config.py"
75
+ config.write_text("app = 1\n")
76
+ with patch.object(
77
+ importlib.util,
78
+ "spec_from_file_location",
79
+ wraps=importlib.util.spec_from_file_location,
80
+ ) as spec_call:
81
+ load_app(str(config))
82
+ # First positional arg after the synthetic module name is the
83
+ # absolute path the loader was asked to read.
84
+ called_path = spec_call.call_args.args[1]
85
+ assert called_path == os.path.abspath(str(config))
@@ -0,0 +1,66 @@
1
+ """Subcommand entry point for `dirsql interpret <config>`.
2
+
3
+ Glues the per-purpose helpers (`load_app`, `dispatch_extract`,
4
+ `write_message`) into the long-running NDJSON loop:
5
+
6
+ handshake (helper -> caller, once on startup):
7
+ {"type": "config", "state": <vars(app)>}
8
+
9
+ extract request (caller -> helper):
10
+ {"type": "extract", "id": <int>, "table": "<name>", "path": "<abs>"}
11
+
12
+ extract response (helper -> caller):
13
+ {"type": "result", "id": <int>, "ok": true, "rows": [...]}
14
+ {"type": "result", "id": <int>, "ok": false, "error": "<msg>"}
15
+
16
+ Exits 0 when stdin closes; non-zero with a single ``dirsql interpret:``
17
+ line on stderr if the config can't be loaded.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import sys
24
+
25
+ from .dispatch_extract import dispatch_extract
26
+ from .load_app import load_app
27
+ from .write_message import write_message
28
+
29
+
30
+ def run(argv: list[str]) -> int:
31
+ if len(argv) != 1:
32
+ sys.stderr.write(
33
+ f"dirsql interpret: expected one config path, got {len(argv)}\n"
34
+ )
35
+ return 1
36
+ config_path = argv[0]
37
+
38
+ try:
39
+ app = load_app(config_path)
40
+ except Exception as exc:
41
+ sys.stderr.write(f"dirsql interpret: {exc}\n")
42
+ return 1
43
+
44
+ # Name comes from `dirsql::db::parse_table_name` -- the canonical
45
+ # core parser, surfaced via PyO3 on `Table.name` (#196). No regex
46
+ # duplication on the Python side. Tables with a name the parser
47
+ # couldn't extract are skipped here; the malformed DDL would also
48
+ # surface as a `DirSqlError::Ddl` during the SDK scan, which
49
+ # `await app.ready()` would re-raise -- not interpret's job to
50
+ # second-guess.
51
+ tables = {t.name: t for t in (app._tables or []) if t.name is not None}
52
+ write_message({"type": "config", "state": vars(app)})
53
+
54
+ for line in sys.stdin:
55
+ stripped = line.strip()
56
+ if not stripped:
57
+ continue
58
+ try:
59
+ req = json.loads(stripped)
60
+ except json.JSONDecodeError:
61
+ continue
62
+ if not isinstance(req, dict) or req.get("type") != "extract":
63
+ continue
64
+ write_message(dispatch_extract(req, tables))
65
+
66
+ return 0
@@ -0,0 +1,172 @@
1
+ """Unit tests for `run`."""
2
+
3
+ import io
4
+ import json
5
+ import sys
6
+ from types import SimpleNamespace
7
+ from unittest.mock import patch
8
+
9
+ from . import run as run_module
10
+ from .run import run
11
+
12
+
13
+ def _fake_table(name: str, extract):
14
+ return SimpleNamespace(name=name, extract=extract)
15
+
16
+
17
+ def _fake_app(tables: list, state: dict):
18
+ """Build a duck-typed `app` -- `_tables` for dispatch, `__dict__`
19
+ handled by overriding `vars`."""
20
+
21
+ class _App:
22
+ _tables = tables
23
+
24
+ a = _App()
25
+ # vars(a) needs to return `state` -- bind it through __dict__.
26
+ a.__dict__.update(state)
27
+ return a
28
+
29
+
30
+ def describe_run():
31
+ def describe_argv_validation():
32
+ def it_returns_1_when_argv_has_no_config_path():
33
+ fake_stderr = io.StringIO()
34
+ with patch.object(sys, "stderr", fake_stderr):
35
+ assert run([]) == 1
36
+ assert fake_stderr.getvalue().startswith("dirsql interpret:")
37
+ assert "got 0" in fake_stderr.getvalue()
38
+
39
+ def it_returns_1_when_argv_has_multiple_paths():
40
+ fake_stderr = io.StringIO()
41
+ with patch.object(sys, "stderr", fake_stderr):
42
+ assert run(["a.py", "b.py"]) == 1
43
+ assert "got 2" in fake_stderr.getvalue()
44
+
45
+ def describe_load_failure():
46
+ def it_returns_1_and_writes_a_dirsql_prefixed_line_to_stderr():
47
+ fake_stderr = io.StringIO()
48
+ with (
49
+ patch.object(
50
+ run_module,
51
+ "load_app",
52
+ side_effect=AttributeError("missing app"),
53
+ ),
54
+ patch.object(sys, "stderr", fake_stderr),
55
+ ):
56
+ assert run(["bad.py"]) == 1
57
+ assert fake_stderr.getvalue() == "dirsql interpret: missing app\n"
58
+
59
+ def describe_handshake():
60
+ def it_writes_a_config_message_with_vars_app_as_state():
61
+ app = _fake_app(tables=[], state={"root": "/x", "ignore": []})
62
+ fake_stdin = io.StringIO("") # immediate EOF
63
+ written: list[dict] = []
64
+ with (
65
+ patch.object(run_module, "load_app", return_value=app),
66
+ patch.object(run_module, "write_message", side_effect=written.append),
67
+ patch.object(sys, "stdin", fake_stdin),
68
+ ):
69
+ assert run(["good.py"]) == 0
70
+ assert written == [
71
+ {"type": "config", "state": {"root": "/x", "ignore": []}}
72
+ ]
73
+
74
+ def describe_extract_loop():
75
+ def it_dispatches_one_extract_request_and_writes_the_response():
76
+ def extract(p):
77
+ return [{"row": p}]
78
+
79
+ app = _fake_app([_fake_table("papers", extract)], state={"x": 1})
80
+ req = {"type": "extract", "id": 1, "table": "papers", "path": "/a"}
81
+ fake_stdin = io.StringIO(json.dumps(req) + "\n")
82
+ written: list[dict] = []
83
+ with (
84
+ patch.object(run_module, "load_app", return_value=app),
85
+ patch.object(run_module, "write_message", side_effect=written.append),
86
+ patch.object(sys, "stdin", fake_stdin),
87
+ ):
88
+ assert run(["good.py"]) == 0
89
+ # written[0] is the handshake; written[1] is the response.
90
+ assert written[1] == {
91
+ "type": "result",
92
+ "id": 1,
93
+ "ok": True,
94
+ "rows": [{"row": "/a"}],
95
+ }
96
+
97
+ def it_skips_blank_lines_silently():
98
+ app = _fake_app([], state={})
99
+ fake_stdin = io.StringIO("\n\n \n")
100
+ written: list[dict] = []
101
+ with (
102
+ patch.object(run_module, "load_app", return_value=app),
103
+ patch.object(run_module, "write_message", side_effect=written.append),
104
+ patch.object(sys, "stdin", fake_stdin),
105
+ ):
106
+ assert run(["good.py"]) == 0
107
+ assert written == [{"type": "config", "state": {}}] # only handshake
108
+
109
+ def it_skips_malformed_json_silently():
110
+ app = _fake_app([], state={})
111
+ fake_stdin = io.StringIO("not json\n{also not\n")
112
+ written: list[dict] = []
113
+ with (
114
+ patch.object(run_module, "load_app", return_value=app),
115
+ patch.object(run_module, "write_message", side_effect=written.append),
116
+ patch.object(sys, "stdin", fake_stdin),
117
+ ):
118
+ assert run(["good.py"]) == 0
119
+ assert written == [{"type": "config", "state": {}}]
120
+
121
+ def it_skips_non_extract_messages_silently():
122
+ app = _fake_app([], state={})
123
+ fake_stdin = io.StringIO('{"type": "ping"}\n')
124
+ written: list[dict] = []
125
+ with (
126
+ patch.object(run_module, "load_app", return_value=app),
127
+ patch.object(run_module, "write_message", side_effect=written.append),
128
+ patch.object(sys, "stdin", fake_stdin),
129
+ ):
130
+ assert run(["good.py"]) == 0
131
+ assert written == [{"type": "config", "state": {}}]
132
+
133
+ def it_skips_non_dict_json_silently():
134
+ app = _fake_app([], state={})
135
+ fake_stdin = io.StringIO("42\n[]\n")
136
+ written: list[dict] = []
137
+ with (
138
+ patch.object(run_module, "load_app", return_value=app),
139
+ patch.object(run_module, "write_message", side_effect=written.append),
140
+ patch.object(sys, "stdin", fake_stdin),
141
+ ):
142
+ assert run(["good.py"]) == 0
143
+ assert written == [{"type": "config", "state": {}}]
144
+
145
+ def it_treats_a_none_tables_attribute_as_empty():
146
+ app = _fake_app(tables=None, state={}) # type: ignore[arg-type]
147
+ fake_stdin = io.StringIO(
148
+ '{"type":"extract","id":1,"table":"x","path":"/y"}\n'
149
+ )
150
+ written: list[dict] = []
151
+ with (
152
+ patch.object(run_module, "load_app", return_value=app),
153
+ patch.object(run_module, "write_message", side_effect=written.append),
154
+ patch.object(sys, "stdin", fake_stdin),
155
+ ):
156
+ assert run(["good.py"]) == 0
157
+ # Handshake + unknown-table response.
158
+ assert written[1]["ok"] is False
159
+ assert "x" in written[1]["error"]
160
+
161
+ def describe_exit():
162
+ def it_returns_0_when_stdin_closes_after_a_run():
163
+ app = _fake_app([_fake_table("t", lambda _p: [])], state={})
164
+ fake_stdin = io.StringIO(
165
+ '{"type":"extract","id":1,"table":"t","path":"/a"}\n'
166
+ )
167
+ with (
168
+ patch.object(run_module, "load_app", return_value=app),
169
+ patch.object(run_module, "write_message"),
170
+ patch.object(sys, "stdin", fake_stdin),
171
+ ):
172
+ assert run(["good.py"]) == 0
@@ -0,0 +1,18 @@
1
+ """Single-line NDJSON writer for the `dirsql interpret` helper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import Any
8
+
9
+
10
+ def write_message(msg: dict[str, Any]) -> None:
11
+ """Write one NDJSON line to stdout and flush.
12
+
13
+ The flush is required so the orchestrator on the other side of the
14
+ pipe sees the message immediately; without it the buffered stream
15
+ would deliver lines in batches.
16
+ """
17
+ sys.stdout.write(json.dumps(msg) + "\n")
18
+ sys.stdout.flush()
@@ -0,0 +1,30 @@
1
+ """Unit tests for `write_message`."""
2
+
3
+ import io
4
+ import sys
5
+ from unittest.mock import patch
6
+
7
+ from .write_message import write_message
8
+
9
+
10
+ def describe_write_message():
11
+ def it_serializes_a_dict_as_one_json_line():
12
+ buf = io.StringIO()
13
+ with patch.object(sys, "stdout", buf):
14
+ write_message({"type": "config", "state": {}})
15
+ assert buf.getvalue() == '{"type": "config", "state": {}}\n'
16
+
17
+ def it_appends_a_trailing_newline():
18
+ buf = io.StringIO()
19
+ with patch.object(sys, "stdout", buf):
20
+ write_message({"x": 1})
21
+ assert buf.getvalue().endswith("\n")
22
+
23
+ def it_flushes_stdout_after_writing():
24
+ fake_stdout = io.StringIO()
25
+ with (
26
+ patch.object(sys, "stdout", fake_stdout),
27
+ patch.object(fake_stdout, "flush") as flush,
28
+ ):
29
+ write_message({"x": 1})
30
+ flush.assert_called_once()