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.
- {dirsql-0.3.15 → dirsql-0.3.17}/Cargo.lock +1 -1
- {dirsql-0.3.15 → dirsql-0.3.17}/PKG-INFO +2 -1
- {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/_async.py +2 -2
- dirsql-0.3.17/dirsql/_dirsql.pyi +63 -0
- dirsql-0.3.17/dirsql/cli/interpret/__init__.py +17 -0
- dirsql-0.3.17/dirsql/cli/interpret/dispatch_extract.py +41 -0
- dirsql-0.3.17/dirsql/cli/interpret/dispatch_extract_test.py +92 -0
- dirsql-0.3.17/dirsql/cli/interpret/load_app.py +32 -0
- dirsql-0.3.17/dirsql/cli/interpret/load_app_test.py +85 -0
- dirsql-0.3.17/dirsql/cli/interpret/run.py +66 -0
- dirsql-0.3.17/dirsql/cli/interpret/run_test.py +172 -0
- dirsql-0.3.17/dirsql/cli/interpret/write_message.py +18 -0
- dirsql-0.3.17/dirsql/cli/interpret/write_message_test.py +30 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/cli/main.py +15 -3
- {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/cli/main_test.py +45 -2
- {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/resolve_config.py +1 -1
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/Cargo.toml +1 -1
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/src/lib.rs +15 -1
- dirsql-0.3.17/packages/python/tests/e2e/__init__.py +0 -0
- dirsql-0.3.17/packages/python/tests/integration/__fixtures__/interpret/data/a/meta.json +1 -0
- dirsql-0.3.17/packages/python/tests/integration/__fixtures__/interpret/data/b/meta.json +1 -0
- dirsql-0.3.17/packages/python/tests/integration/__fixtures__/interpret/dirsql.config.py +27 -0
- dirsql-0.3.17/packages/python/tests/integration/__fixtures__/interpret/dirsql.config_no_app.py +3 -0
- dirsql-0.3.17/packages/python/tests/integration/__fixtures__/interpret/dirsql.config_raises.py +21 -0
- dirsql-0.3.17/packages/python/tests/integration/interpret_subprocess.py +96 -0
- dirsql-0.3.17/packages/python/tests/integration/test_interpret.py +145 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/pyproject.toml +32 -5
- {dirsql-0.3.15 → dirsql-0.3.17}/Cargo.toml +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/README.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/__init__.py +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/cli/__init__.py +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/cli/binary_path.py +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/cli/binary_path_test.py +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/cli/is_windows.py +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/cli/is_windows_test.py +0 -0
- /dirsql-0.3.15/packages/python/tests/e2e/__init__.py → /dirsql-0.3.17/dirsql/py.typed +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/resolve_config_test.py +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/dirsql/test_async.py +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/.claude/CLAUDE.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/.vitepress/config.ts +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/.vitepress/theme/index.ts +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/.vitepress/theme/lang.ts +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/AGENTS.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/api/index.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/cli/config.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/cli/http-api.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/cli/index.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/cli/init.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/cli/server.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/getting-started.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/guide/async.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/guide/crdt.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/guide/persistence.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/guide/querying.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/guide/tables.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/guide/watching.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/index.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/migrations.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/package.json +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/playwright.config.ts +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/pnpm-lock.yaml +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/pnpm-workspace.yaml +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/tests/integration/home.spec.ts +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/tests/integration/language-flag.spec.ts +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/tests/unit/config.test.ts +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/tests/unit/lang.test.ts +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/docs/vitest.config.ts +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/README.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/conftest.py +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/.claude/CLAUDE.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/.vitepress/config.ts +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/.vitepress/theme/index.ts +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/.vitepress/theme/lang.ts +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/AGENTS.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/api/index.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/cli/config.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/cli/http-api.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/cli/index.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/cli/init.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/cli/server.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/getting-started.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/guide/async.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/guide/crdt.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/guide/persistence.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/guide/querying.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/guide/tables.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/guide/watching.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/index.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/migrations.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/package.json +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/playwright.config.ts +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/pnpm-lock.yaml +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/pnpm-workspace.yaml +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/tests/integration/home.spec.ts +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/tests/integration/language-flag.spec.ts +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/tests/unit/config.test.ts +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/tests/unit/lang.test.ts +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/docs/vitest.config.ts +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/tests/__init__.py +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/tests/conftest.py +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/tests/integration/__init__.py +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/tests/integration/test_async_dirsql.py +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/tests/integration/test_binding.py +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/tests/integration/test_dirsql.py +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/tests/integration/test_docs_examples.py +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/tests/integration/test_docs_gaps.py +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/tests/integration/test_from_config.py +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/tests/integration/test_persist.py +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/python/tests/integration/test_serialization.py +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/Cargo.toml +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/README.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/benches/db_bench.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/benches/differ_bench.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/benches/matcher_bench.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/benches/scanner_bench.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/api/index.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/cli/config.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/cli/http-api.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/cli/index.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/cli/init.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/cli/server.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/getting-started.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/guide/async.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/guide/crdt.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/guide/persistence.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/guide/querying.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/guide/tables.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/guide/watching.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/index.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/docs/migrations.md +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/bin/dirsql.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/cli/init.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/cli/mod.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/cli/router.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/cli/serialize.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/cli/server.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/config.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/db.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/differ.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/lib.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/matcher.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/persist.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/scanner.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/src/watcher.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/async_sdk.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/cli_e2e.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/cli_integration.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/docs_examples.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/docs_gaps.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/from_config.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/init_e2e.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/init_integration.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/persist.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/readonly_query.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/sdk.rs +0 -0
- {dirsql-0.3.15 → dirsql-0.3.17}/packages/rust/tests/serialization.rs +0 -0
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dirsql
|
|
3
|
-
Version: 0.3.
|
|
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()
|