dirsql 0.3.16__tar.gz → 0.3.18__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 (161) hide show
  1. {dirsql-0.3.16 → dirsql-0.3.18}/Cargo.lock +1 -1
  2. {dirsql-0.3.16 → dirsql-0.3.18}/PKG-INFO +1 -1
  3. dirsql-0.3.18/dirsql/cli/interpret/__init__.py +17 -0
  4. dirsql-0.3.18/dirsql/cli/interpret/dispatch_extract.py +41 -0
  5. dirsql-0.3.18/dirsql/cli/interpret/dispatch_extract_test.py +92 -0
  6. dirsql-0.3.18/dirsql/cli/interpret/load_app.py +32 -0
  7. dirsql-0.3.18/dirsql/cli/interpret/load_app_test.py +85 -0
  8. dirsql-0.3.18/dirsql/cli/interpret/run.py +66 -0
  9. dirsql-0.3.18/dirsql/cli/interpret/run_test.py +172 -0
  10. dirsql-0.3.18/dirsql/cli/interpret/write_message.py +18 -0
  11. dirsql-0.3.18/dirsql/cli/interpret/write_message_test.py +30 -0
  12. {dirsql-0.3.16 → dirsql-0.3.18}/dirsql/cli/main.py +15 -3
  13. {dirsql-0.3.16 → dirsql-0.3.18}/dirsql/cli/main_test.py +45 -2
  14. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/Cargo.toml +1 -1
  15. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/src/lib.rs +15 -1
  16. dirsql-0.3.18/packages/python/tests/integration/__fixtures__/data/a/meta.json +1 -0
  17. dirsql-0.3.18/packages/python/tests/integration/__fixtures__/data/b/meta.json +1 -0
  18. dirsql-0.3.18/packages/python/tests/integration/__fixtures__/dirsql.config.py +29 -0
  19. dirsql-0.3.18/packages/python/tests/integration/__fixtures__/interpret/data/a/meta.json +1 -0
  20. dirsql-0.3.18/packages/python/tests/integration/__fixtures__/interpret/data/b/meta.json +1 -0
  21. dirsql-0.3.18/packages/python/tests/integration/__fixtures__/interpret/dirsql.config.py +27 -0
  22. dirsql-0.3.18/packages/python/tests/integration/__fixtures__/interpret/dirsql.config_no_app.py +3 -0
  23. dirsql-0.3.18/packages/python/tests/integration/__fixtures__/interpret/dirsql.config_raises.py +21 -0
  24. dirsql-0.3.18/packages/python/tests/integration/interpret_subprocess.py +96 -0
  25. dirsql-0.3.18/packages/python/tests/integration/test_interpret.py +145 -0
  26. dirsql-0.3.18/packages/python/tests/integration/test_native_config.py +100 -0
  27. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/src/bin/dirsql.rs +67 -1
  28. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/src/cli/mod.rs +1 -0
  29. dirsql-0.3.18/packages/rust/src/cli/native_config.rs +694 -0
  30. {dirsql-0.3.16 → dirsql-0.3.18}/pyproject.toml +4 -5
  31. {dirsql-0.3.16 → dirsql-0.3.18}/Cargo.toml +0 -0
  32. {dirsql-0.3.16 → dirsql-0.3.18}/README.md +0 -0
  33. {dirsql-0.3.16 → dirsql-0.3.18}/dirsql/__init__.py +0 -0
  34. {dirsql-0.3.16 → dirsql-0.3.18}/dirsql/_async.py +0 -0
  35. {dirsql-0.3.16 → dirsql-0.3.18}/dirsql/_dirsql.pyi +0 -0
  36. {dirsql-0.3.16 → dirsql-0.3.18}/dirsql/cli/__init__.py +0 -0
  37. {dirsql-0.3.16 → dirsql-0.3.18}/dirsql/cli/binary_path.py +0 -0
  38. {dirsql-0.3.16 → dirsql-0.3.18}/dirsql/cli/binary_path_test.py +0 -0
  39. {dirsql-0.3.16 → dirsql-0.3.18}/dirsql/cli/is_windows.py +0 -0
  40. {dirsql-0.3.16 → dirsql-0.3.18}/dirsql/cli/is_windows_test.py +0 -0
  41. {dirsql-0.3.16 → dirsql-0.3.18}/dirsql/py.typed +0 -0
  42. {dirsql-0.3.16 → dirsql-0.3.18}/dirsql/resolve_config.py +0 -0
  43. {dirsql-0.3.16 → dirsql-0.3.18}/dirsql/resolve_config_test.py +0 -0
  44. {dirsql-0.3.16 → dirsql-0.3.18}/dirsql/test_async.py +0 -0
  45. {dirsql-0.3.16 → dirsql-0.3.18}/docs/.claude/CLAUDE.md +0 -0
  46. {dirsql-0.3.16 → dirsql-0.3.18}/docs/.vitepress/config.ts +0 -0
  47. {dirsql-0.3.16 → dirsql-0.3.18}/docs/.vitepress/theme/index.ts +0 -0
  48. {dirsql-0.3.16 → dirsql-0.3.18}/docs/.vitepress/theme/lang.ts +0 -0
  49. {dirsql-0.3.16 → dirsql-0.3.18}/docs/AGENTS.md +0 -0
  50. {dirsql-0.3.16 → dirsql-0.3.18}/docs/api/index.md +0 -0
  51. {dirsql-0.3.16 → dirsql-0.3.18}/docs/cli/config.md +0 -0
  52. {dirsql-0.3.16 → dirsql-0.3.18}/docs/cli/http-api.md +0 -0
  53. {dirsql-0.3.16 → dirsql-0.3.18}/docs/cli/index.md +0 -0
  54. {dirsql-0.3.16 → dirsql-0.3.18}/docs/cli/init.md +0 -0
  55. {dirsql-0.3.16 → dirsql-0.3.18}/docs/cli/server.md +0 -0
  56. {dirsql-0.3.16 → dirsql-0.3.18}/docs/getting-started.md +0 -0
  57. {dirsql-0.3.16 → dirsql-0.3.18}/docs/guide/async.md +0 -0
  58. {dirsql-0.3.16 → dirsql-0.3.18}/docs/guide/crdt.md +0 -0
  59. {dirsql-0.3.16 → dirsql-0.3.18}/docs/guide/persistence.md +0 -0
  60. {dirsql-0.3.16 → dirsql-0.3.18}/docs/guide/querying.md +0 -0
  61. {dirsql-0.3.16 → dirsql-0.3.18}/docs/guide/tables.md +0 -0
  62. {dirsql-0.3.16 → dirsql-0.3.18}/docs/guide/watching.md +0 -0
  63. {dirsql-0.3.16 → dirsql-0.3.18}/docs/index.md +0 -0
  64. {dirsql-0.3.16 → dirsql-0.3.18}/docs/migrations.md +0 -0
  65. {dirsql-0.3.16 → dirsql-0.3.18}/docs/package.json +0 -0
  66. {dirsql-0.3.16 → dirsql-0.3.18}/docs/playwright.config.ts +0 -0
  67. {dirsql-0.3.16 → dirsql-0.3.18}/docs/pnpm-lock.yaml +0 -0
  68. {dirsql-0.3.16 → dirsql-0.3.18}/docs/pnpm-workspace.yaml +0 -0
  69. {dirsql-0.3.16 → dirsql-0.3.18}/docs/tests/integration/home.spec.ts +0 -0
  70. {dirsql-0.3.16 → dirsql-0.3.18}/docs/tests/integration/language-flag.spec.ts +0 -0
  71. {dirsql-0.3.16 → dirsql-0.3.18}/docs/tests/unit/config.test.ts +0 -0
  72. {dirsql-0.3.16 → dirsql-0.3.18}/docs/tests/unit/lang.test.ts +0 -0
  73. {dirsql-0.3.16 → dirsql-0.3.18}/docs/vitest.config.ts +0 -0
  74. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/README.md +0 -0
  75. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/conftest.py +0 -0
  76. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/.claude/CLAUDE.md +0 -0
  77. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/.vitepress/config.ts +0 -0
  78. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/.vitepress/theme/index.ts +0 -0
  79. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/.vitepress/theme/lang.ts +0 -0
  80. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/AGENTS.md +0 -0
  81. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/api/index.md +0 -0
  82. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/cli/config.md +0 -0
  83. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/cli/http-api.md +0 -0
  84. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/cli/index.md +0 -0
  85. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/cli/init.md +0 -0
  86. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/cli/server.md +0 -0
  87. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/getting-started.md +0 -0
  88. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/guide/async.md +0 -0
  89. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/guide/crdt.md +0 -0
  90. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/guide/persistence.md +0 -0
  91. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/guide/querying.md +0 -0
  92. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/guide/tables.md +0 -0
  93. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/guide/watching.md +0 -0
  94. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/index.md +0 -0
  95. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/migrations.md +0 -0
  96. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/package.json +0 -0
  97. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/playwright.config.ts +0 -0
  98. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/pnpm-lock.yaml +0 -0
  99. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/pnpm-workspace.yaml +0 -0
  100. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/tests/integration/home.spec.ts +0 -0
  101. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/tests/integration/language-flag.spec.ts +0 -0
  102. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/tests/unit/config.test.ts +0 -0
  103. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/tests/unit/lang.test.ts +0 -0
  104. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/docs/vitest.config.ts +0 -0
  105. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/tests/__init__.py +0 -0
  106. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/tests/conftest.py +0 -0
  107. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/tests/e2e/__init__.py +0 -0
  108. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/tests/integration/__init__.py +0 -0
  109. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/tests/integration/test_async_dirsql.py +0 -0
  110. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/tests/integration/test_binding.py +0 -0
  111. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/tests/integration/test_dirsql.py +0 -0
  112. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/tests/integration/test_docs_examples.py +0 -0
  113. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/tests/integration/test_docs_gaps.py +0 -0
  114. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/tests/integration/test_from_config.py +0 -0
  115. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/tests/integration/test_persist.py +0 -0
  116. {dirsql-0.3.16 → dirsql-0.3.18}/packages/python/tests/integration/test_serialization.py +0 -0
  117. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/Cargo.toml +0 -0
  118. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/README.md +0 -0
  119. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/benches/db_bench.rs +0 -0
  120. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/benches/differ_bench.rs +0 -0
  121. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/benches/matcher_bench.rs +0 -0
  122. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/benches/scanner_bench.rs +0 -0
  123. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/docs/api/index.md +0 -0
  124. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/docs/cli/config.md +0 -0
  125. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/docs/cli/http-api.md +0 -0
  126. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/docs/cli/index.md +0 -0
  127. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/docs/cli/init.md +0 -0
  128. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/docs/cli/server.md +0 -0
  129. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/docs/getting-started.md +0 -0
  130. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/docs/guide/async.md +0 -0
  131. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/docs/guide/crdt.md +0 -0
  132. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/docs/guide/persistence.md +0 -0
  133. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/docs/guide/querying.md +0 -0
  134. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/docs/guide/tables.md +0 -0
  135. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/docs/guide/watching.md +0 -0
  136. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/docs/index.md +0 -0
  137. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/docs/migrations.md +0 -0
  138. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/src/cli/init.rs +0 -0
  139. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/src/cli/router.rs +0 -0
  140. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/src/cli/serialize.rs +0 -0
  141. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/src/cli/server.rs +0 -0
  142. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/src/config.rs +0 -0
  143. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/src/db.rs +0 -0
  144. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/src/differ.rs +0 -0
  145. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/src/lib.rs +0 -0
  146. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/src/matcher.rs +0 -0
  147. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/src/persist.rs +0 -0
  148. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/src/scanner.rs +0 -0
  149. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/src/watcher.rs +0 -0
  150. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/tests/async_sdk.rs +0 -0
  151. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/tests/cli_e2e.rs +0 -0
  152. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/tests/cli_integration.rs +0 -0
  153. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/tests/docs_examples.rs +0 -0
  154. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/tests/docs_gaps.rs +0 -0
  155. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/tests/from_config.rs +0 -0
  156. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/tests/init_e2e.rs +0 -0
  157. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/tests/init_integration.rs +0 -0
  158. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/tests/persist.rs +0 -0
  159. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/tests/readonly_query.rs +0 -0
  160. {dirsql-0.3.16 → dirsql-0.3.18}/packages/rust/tests/sdk.rs +0 -0
  161. {dirsql-0.3.16 → dirsql-0.3.18}/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.16"
502
+ version = "0.3.18"
503
503
  dependencies = [
504
504
  "dirsql",
505
505
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirsql
3
- Version: 0.3.16
3
+ Version: 0.3.18
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'
@@ -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()
@@ -1,5 +1,8 @@
1
1
  """Console-script entry point. Execs the bundled binary on POSIX,
2
- subprocesses it on Windows."""
2
+ subprocesses it on Windows. When ``argv[0] == "interpret"`` the
3
+ in-process Python helper handles the subcommand directly so a Rust
4
+ orchestrator can spawn this script for native-language configs (#196)
5
+ without depending on the bundled Rust binary."""
3
6
 
4
7
  from __future__ import annotations
5
8
 
@@ -7,13 +10,22 @@ import os
7
10
  import subprocess
8
11
  import sys
9
12
 
10
- from dirsql.cli.binary_path import binary_path
11
- from dirsql.cli.is_windows import is_windows
13
+ from .binary_path import binary_path
14
+ from .is_windows import is_windows
12
15
 
13
16
 
14
17
  def main(argv: list[str] | None = None) -> int:
15
18
  if argv is None:
16
19
  argv = sys.argv[1:]
20
+
21
+ if argv and argv[0] == "interpret":
22
+ from .interpret.run import run
23
+
24
+ try:
25
+ return run(argv[1:])
26
+ except KeyboardInterrupt:
27
+ return 130
28
+
17
29
  try:
18
30
  binary = binary_path()
19
31
  except FileNotFoundError as exc:
@@ -6,8 +6,8 @@ import subprocess
6
6
  import sys
7
7
  from unittest.mock import patch
8
8
 
9
- from dirsql.cli import main as main_module
10
- from dirsql.cli.main import main
9
+ from . import main as main_module
10
+ from .main import main
11
11
 
12
12
 
13
13
  class _Completed:
@@ -85,3 +85,46 @@ def describe_main():
85
85
  ):
86
86
  main(argv=None)
87
87
  run.assert_called_once_with(["C:/dirsql.exe", "--help"])
88
+
89
+ def describe_when_argv0_is_interpret():
90
+ """`dirsql interpret <config>` is handled in-process so the Rust
91
+ orchestrator can spawn the launcher for native-language configs
92
+ (#196) without depending on the bundled Rust binary."""
93
+
94
+ def it_dispatches_to_interpret_run_and_returns_its_exit_code():
95
+ # main.py does `from .interpret.run import run` lazily, so
96
+ # patching the `run` function on the submodule is sufficient
97
+ # -- each call resolves the name freshly from the submodule.
98
+ from .interpret import run as run_submod
99
+
100
+ with (
101
+ patch.object(run_submod, "run", return_value=0) as interpret_run,
102
+ patch.object(main_module, "binary_path") as binary_path,
103
+ ):
104
+ assert main(["interpret", "config.py"]) == 0
105
+ interpret_run.assert_called_once_with(["config.py"])
106
+ binary_path.assert_not_called()
107
+
108
+ def it_propagates_a_nonzero_interpret_exit():
109
+ from .interpret import run as run_submod
110
+
111
+ with patch.object(run_submod, "run", return_value=2):
112
+ assert main(["interpret", "bad.py"]) == 2
113
+
114
+ def it_returns_130_on_keyboard_interrupt():
115
+ from .interpret import run as run_submod
116
+
117
+ with patch.object(run_submod, "run", side_effect=KeyboardInterrupt()):
118
+ assert main(["interpret", "config.py"]) == 130
119
+
120
+ def it_does_not_intercept_when_interpret_is_not_argv_0():
121
+ # `dirsql --verbose interpret` is the binary's problem, not
122
+ # ours -- the in-process route only fires on the first arg.
123
+ with (
124
+ patch.object(main_module, "binary_path", return_value="/bin/dirsql"),
125
+ patch.object(main_module, "is_windows", return_value=True),
126
+ patch.object(subprocess, "run", return_value=_Completed(0)),
127
+ ):
128
+ main(["--verbose", "interpret", "config.py"])
129
+ # binary_path was reached, meaning we did NOT take the
130
+ # interpret shortcut.