dirsql 0.3.82__cp311-abi3-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dirsql/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """dirsql - Ephemeral SQL index over a local directory.
2
+
3
+ Also available for Rust (crates.io: ``dirsql``) and TypeScript (npm: ``dirsql``).
4
+ """
5
+
6
+ from dirsql._dirsql import Table, RowEvent, __version__
7
+ from dirsql._async import DirSQL
8
+
9
+ __all__ = ["DirSQL", "Table", "RowEvent", "__version__"]
dirsql/_async.py ADDED
@@ -0,0 +1,184 @@
1
+ """Async-by-default DirSQL wrapper."""
2
+
3
+ import asyncio
4
+ import os
5
+
6
+ from dirsql._dirsql import DirSQL as _RustDirSQL
7
+ from dirsql.resolve_config_extensions import resolve_config_extension_specs
8
+ from dirsql.resolve_extension import resolve_extension_path
9
+
10
+
11
+ class _WatchStream:
12
+ """Async iterator that polls for file events."""
13
+
14
+ def __init__(self, owner):
15
+ self._owner = owner
16
+ self._db = None
17
+ self._started = False
18
+ self._buffer = []
19
+
20
+ def __aiter__(self):
21
+ return self
22
+
23
+ async def __anext__(self):
24
+ if not self._started:
25
+ await self._owner.ready()
26
+ db = self._owner._db
27
+ assert db is not None # ready() returned, so _init_bg set _db
28
+ self._db = db
29
+ await asyncio.to_thread(db._start_watcher)
30
+ self._started = True
31
+
32
+ db = self._db
33
+ assert db is not None
34
+ while True:
35
+ if self._buffer:
36
+ return self._buffer.pop(0)
37
+ events = await asyncio.to_thread(db._poll_events, 200)
38
+ if events:
39
+ self._buffer.extend(events)
40
+
41
+
42
+ class DirSQL:
43
+ """Async-by-default wrapper around the Rust DirSQL engine.
44
+
45
+ Usage:
46
+ # Programmatic:
47
+ db = DirSQL(root, tables=[...])
48
+ # From a config file:
49
+ db = DirSQL(config="./my-config.toml")
50
+
51
+ await db.ready()
52
+ results = await db.query("SELECT ...")
53
+ async for event in db.watch():
54
+ ...
55
+
56
+ Supply a ``root``, a ``config`` path, or both. When both are set, the
57
+ explicit ``root`` wins over any ``[dirsql].root`` in the config file (a
58
+ warning is emitted on stderr). If neither is given, the initial scan
59
+ fails with a "no root directory" error raised by the core.
60
+
61
+ Pass ``persist=True`` to keep an on-disk SQLite cache (default location:
62
+ ``<root>/.dirsql/cache.db``). Override the location with ``persist_path``.
63
+
64
+ Pass ``extensions`` -- a list of ``{"path": ..., "entrypoint": ...}`` dicts
65
+ (``entrypoint`` optional) -- to load SQLite extensions onto the connection
66
+ at startup. Any ``[[dirsql.extension]]`` entries in a ``config`` file are
67
+ appended after the programmatic ones. A ``path`` (programmatic or
68
+ config-file) may be a bare **package name**, resolved from the installed
69
+ package in the runtime env.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ root=None,
75
+ *,
76
+ tables=None,
77
+ ignore=None,
78
+ config=None,
79
+ persist=False,
80
+ persist_path=None,
81
+ extensions=None,
82
+ ):
83
+ self._root = root
84
+ self._tables = tables
85
+ self._ignore = ignore
86
+ self._config = config
87
+ self._persist = persist
88
+ self._persist_path = persist_path
89
+ self._extensions = extensions
90
+ self._db = None
91
+ self._ready_event = asyncio.Event()
92
+ self._init_error = None
93
+ self._task = asyncio.ensure_future(self._init_bg())
94
+
95
+ async def _init_bg(self):
96
+ """Run the scan in the background."""
97
+ try:
98
+ self._db = await asyncio.to_thread(self._build_db)
99
+ except Exception as exc:
100
+ self._init_error = exc
101
+ finally:
102
+ self._ready_event.set()
103
+
104
+ def _build_db(self):
105
+ """Resolve extensions and construct the Rust-backed instance.
106
+
107
+ Runs on a worker thread (via ``asyncio.to_thread``): both the
108
+ package-name resolution and the core's initial scan are blocking.
109
+
110
+ When the ``config`` file names an extension by bare package name, the
111
+ SDK resolves every one of the config's ``[[dirsql.extension]]`` entries
112
+ itself -- appended after the programmatic ones -- and suppresses the
113
+ core's own config-extension loading so the entries are not loaded a
114
+ second time (the core cannot resolve a bare name).
115
+ """
116
+ extensions = self._resolved_extensions()
117
+ suppress = False
118
+ if self._config is not None:
119
+ config_extensions = resolve_config_extension_specs(self._config)
120
+ if config_extensions is not None:
121
+ extensions = [*(extensions or []), *config_extensions]
122
+ suppress = True
123
+ return _RustDirSQL(
124
+ self._root,
125
+ tables=self._tables,
126
+ ignore=self._ignore,
127
+ config=self._config,
128
+ persist=self._persist,
129
+ persist_path=self._persist_path,
130
+ extensions=extensions,
131
+ suppress_config_extensions=suppress,
132
+ )
133
+
134
+ def _resolved_extensions(self):
135
+ """Resolve each programmatic extension's ``path`` to a loadable file.
136
+
137
+ A bare package name is resolved to the loadable installed in the
138
+ runtime env; path-looking values pass through verbatim. Config-file
139
+ ``[[dirsql.extension]]`` entries are handled by ``_build_db``.
140
+ """
141
+ if not self._extensions:
142
+ return self._extensions
143
+ return [
144
+ {
145
+ "path": resolve_extension_path(
146
+ e["path"], base=os.getcwd(), resolve_relative=False
147
+ ),
148
+ "entrypoint": e.get("entrypoint"),
149
+ }
150
+ for e in self._extensions
151
+ ]
152
+
153
+ async def ready(self):
154
+ """Wait until the initial scan is complete.
155
+
156
+ Raises any exception that occurred during init.
157
+ Can be called multiple times safely.
158
+ """
159
+ await self._ready_event.wait()
160
+ if self._init_error is not None:
161
+ raise self._init_error
162
+
163
+ async def query(self, sql):
164
+ """Execute a SQL query asynchronously.
165
+
166
+ Awaits :meth:`ready` first, so calling ``query`` before an explicit
167
+ ``await db.ready()`` waits for the background scan (and re-raises any
168
+ initialization error) instead of failing on a still-``None`` ``_db``.
169
+ """
170
+ await self.ready()
171
+ db = self._db
172
+ assert db is not None # ready() returned, so _init_bg set _db
173
+ return await asyncio.to_thread(db.query, sql)
174
+
175
+ def watch(self):
176
+ """Start watching for file changes. Returns an async iterable of RowEvent.
177
+
178
+ Like :meth:`query`, the returned stream awaits :meth:`ready` on its
179
+ first iteration before starting the watcher, so calling ``watch``
180
+ before an explicit ``await db.ready()`` waits for the background scan
181
+ (and surfaces any initialization error) instead of failing on a
182
+ still-``None`` ``_db``.
183
+ """
184
+ return _WatchStream(self)
Binary file
dirsql/_dirsql.pyd ADDED
Binary file
dirsql/_dirsql.pyi ADDED
@@ -0,0 +1,76 @@
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, NotRequired, TypedDict
15
+
16
+ from typing_extensions import override
17
+
18
+ __version__: str
19
+
20
+ Row = dict[str, Any]
21
+
22
+ class ExtensionSpec(TypedDict):
23
+ """A SQLite extension to load at startup: a shared-library ``path`` and an
24
+ optional ``entrypoint`` init-symbol override. Mirrors a
25
+ ``[[dirsql.extension]]`` config entry."""
26
+
27
+ path: str
28
+ entrypoint: NotRequired[str]
29
+
30
+ class Table:
31
+ """A table definition. Construct via keyword arguments only."""
32
+
33
+ ddl: str
34
+ glob: str
35
+ strict: bool
36
+
37
+ def __init__(
38
+ self,
39
+ *,
40
+ ddl: str,
41
+ glob: str,
42
+ extract: Callable[[str], list[Row]],
43
+ strict: bool = False,
44
+ ) -> None: ...
45
+
46
+ class RowEvent:
47
+ """A row event produced by the watch loop."""
48
+
49
+ table: str | None
50
+ action: str
51
+ row: Row | None
52
+ old_row: Row | None
53
+ error: str | None
54
+ file_path: str | None
55
+
56
+ @override
57
+ def __repr__(self) -> str: ...
58
+
59
+ class DirSQL:
60
+ """Synchronous binding class. ``dirsql._async.DirSQL`` wraps it."""
61
+
62
+ def __init__(
63
+ self,
64
+ root: str | None = None,
65
+ *,
66
+ tables: list[Table] | None = None,
67
+ ignore: list[str] | None = None,
68
+ config: str | None = None,
69
+ persist: bool = False,
70
+ persist_path: str | PathLike[str] | None = None,
71
+ extensions: list[ExtensionSpec] | None = None,
72
+ suppress_config_extensions: bool = False,
73
+ ) -> None: ...
74
+ def query(self, sql: str) -> list[Row]: ...
75
+ def _start_watcher(self) -> None: ...
76
+ def _poll_events(self, timeout_ms: int) -> list[RowEvent]: ...
dirsql/cli/__init__.py ADDED
File without changes
@@ -0,0 +1,20 @@
1
+ """Resolve the bundled Rust binary inside the installed wheel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.resources import files
6
+
7
+ from dirsql.cli.is_windows import is_windows
8
+
9
+
10
+ def binary_path() -> str:
11
+ name = "dirsql.exe" if is_windows() else "dirsql"
12
+ path = files("dirsql").joinpath("_binary", name)
13
+ if not path.is_file():
14
+ raise FileNotFoundError(
15
+ f"bundled `{name}` not found at {path}. The dirsql PyPI wheel "
16
+ "no longer ships the CLI binary (release-tooling regression "
17
+ "while putitoutthere wires up bundle_cli). Install the CLI via "
18
+ "`cargo install dirsql --features cli` or `npx dirsql`."
19
+ )
20
+ return str(path)
@@ -0,0 +1,9 @@
1
+ """Platform check used by the launcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+
8
+ def is_windows() -> bool:
9
+ return os.name == "nt"
dirsql/cli/main.py ADDED
@@ -0,0 +1,38 @@
1
+ """Console-script entry point. Execs the bundled binary on POSIX,
2
+ subprocesses it on Windows. All argv is forwarded transparently to the
3
+ bundled Rust binary."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import subprocess
9
+ import sys
10
+
11
+ from .binary_path import binary_path
12
+ from .is_windows import is_windows
13
+ from .resolve_config_extensions import with_resolved_extensions
14
+
15
+
16
+ def main(argv: list[str] | None = None) -> int:
17
+ if argv is None:
18
+ argv = sys.argv[1:]
19
+
20
+ try:
21
+ binary = binary_path()
22
+ except FileNotFoundError as exc:
23
+ print(f"dirsql: {exc}", file=sys.stderr)
24
+ return 1
25
+
26
+ # Resolve any package-name extensions in a TOML config here (the binary
27
+ # can't) and pass them as `--extension` flags; a no-op otherwise.
28
+ try:
29
+ argv = with_resolved_extensions(argv)
30
+ except Exception as exc:
31
+ print(f"dirsql: {exc}", file=sys.stderr)
32
+ return 1
33
+
34
+ if is_windows():
35
+ completed = subprocess.run([binary, *argv])
36
+ return completed.returncode
37
+ os.execv(binary, [binary, *argv])
38
+ return 0
@@ -0,0 +1,55 @@
1
+ """Launcher-side resolution of a TOML config's ``[[dirsql.extension]]`` entries.
2
+
3
+ The compiled ``dirsql`` binary loads a config's extensions literally -- it
4
+ has no ``importlib``, so it cannot resolve a bare **package name**. When a
5
+ TOML config names an extension by package name, the shared SDK resolver
6
+ (:mod:`dirsql.resolve_config_extensions`) resolves every one of its
7
+ extensions and this launcher passes the resolved literal paths to the binary
8
+ via repeatable ``--extension`` flags; the binary then loads those and ignores
9
+ the config's own extension entries.
10
+
11
+ Native-language configs (``.py`` / ``.js`` / ``.mjs`` / ``.cjs``) are untouched:
12
+ the binary dispatches those to ``dirsql interpret``, whose handshake already
13
+ carries resolved paths.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from ..resolve_config_extensions import resolve_config_extension_specs
19
+
20
+ # Config extensions the binary dispatches to `dirsql interpret`; never
21
+ # pre-resolved here (that path resolves via the handshake).
22
+ _NATIVE_SUFFIXES = (".py", ".js", ".mjs", ".cjs")
23
+
24
+
25
+ def _config_path_from_argv(argv: list[str]) -> str:
26
+ """The ``--config`` value (``--config X`` or ``--config=X``), or the default."""
27
+ for i, a in enumerate(argv):
28
+ if a == "--config":
29
+ # A bare trailing `--config` (no following value) yields "".
30
+ return next(iter(argv[i + 1 :]), "")
31
+ if a.startswith("--config="):
32
+ return a[len("--config=") :]
33
+ return "./.dirsql.toml"
34
+
35
+
36
+ def with_resolved_extensions(argv: list[str]) -> list[str]:
37
+ """Return ``argv`` plus ``--extension`` flags when the TOML config names an
38
+ extension by package name; otherwise return ``argv`` unchanged. Raises if a
39
+ package name cannot be resolved (the launcher surfaces a clean error)."""
40
+ if argv and argv[0] == "init":
41
+ return argv
42
+ config_path = _config_path_from_argv(argv)
43
+ if config_path.endswith(_NATIVE_SUFFIXES):
44
+ return argv
45
+ specs = resolve_config_extension_specs(config_path)
46
+ if specs is None:
47
+ return argv
48
+ flags: list[str] = []
49
+ for spec in specs:
50
+ entrypoint = spec["entrypoint"]
51
+ flags.append("--extension")
52
+ flags.append(
53
+ f"{spec['path']}::{entrypoint}" if entrypoint is not None else spec["path"]
54
+ )
55
+ return [*argv, *flags]
dirsql/py.typed ADDED
File without changes
@@ -0,0 +1,67 @@
1
+ """SDK-side resolution of a TOML config's ``[[dirsql.extension]]`` entries.
2
+
3
+ The Rust core loads a config's extensions literally -- it has no
4
+ ``importlib``, so it cannot resolve a bare **package name**. When a TOML
5
+ config names an extension by package name, the SDK resolves every one of its
6
+ extensions here, hands the core the resolved literal paths, and suppresses
7
+ the core's own config-extension loading (``suppress_config_extensions``) so
8
+ the config's entries are not loaded a second time.
9
+
10
+ Shared by the ``DirSQL`` constructor (``config=`` path) and the CLI launcher
11
+ (which converts the resolved specs into ``--extension`` flags).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import tomllib
18
+
19
+ from .resolve_extension import is_bare_name, resolve_extension_path
20
+
21
+
22
+ def resolve_config_extension_specs(config_path):
23
+ """Resolve a TOML config's ``[[dirsql.extension]]`` entries to literal paths.
24
+
25
+ Returns a list of ``{"path", "entrypoint"}`` dicts -- every entry resolved
26
+ via :func:`resolve_extension_path` against the config file's parent
27
+ directory -- when at least one entry's ``path`` is a bare package name.
28
+ Returns ``None`` when the caller should not intervene: the config is
29
+ missing, malformed, declares no extensions, or uses only literal paths --
30
+ leaving the core's own loading (and error reporting) untouched. Raises if
31
+ a package name cannot be resolved.
32
+ """
33
+ if not os.path.isfile(config_path):
34
+ return None
35
+ try:
36
+ with open(config_path, "rb") as f:
37
+ doc = tomllib.load(f)
38
+ except (OSError, tomllib.TOMLDecodeError):
39
+ # Leave a malformed / unreadable config for the core to report.
40
+ return None
41
+
42
+ cfg = doc.get("dirsql") or {}
43
+ entries = cfg.get("extension")
44
+ if not isinstance(entries, list):
45
+ return None
46
+ # Only intervene when at least one path is a bare package name.
47
+ if not any(
48
+ isinstance(e, dict)
49
+ and isinstance(e.get("path"), str)
50
+ and is_bare_name(e["path"])
51
+ for e in entries
52
+ ):
53
+ return None
54
+
55
+ base = os.path.dirname(os.path.abspath(config_path))
56
+ specs = []
57
+ for e in entries:
58
+ entrypoint = e.get("entrypoint")
59
+ specs.append(
60
+ {
61
+ "path": resolve_extension_path(
62
+ e["path"], base=base, resolve_relative=True
63
+ ),
64
+ "entrypoint": entrypoint if isinstance(entrypoint, str) else None,
65
+ }
66
+ )
67
+ return specs
@@ -0,0 +1,101 @@
1
+ """Resolve an extension entry's ``path`` to a concrete loadable file.
2
+
3
+ Resolution is an ordered probe (file-first, then package), so only a bare
4
+ package name reaches the package machinery:
5
+
6
+ 1. **Path-looking** (contains a separator, or ends in ``.so`` / ``.dylib`` /
7
+ ``.dll`` / ``.pyd``) -- returned as a file path: made absolute against
8
+ ``base`` when ``resolve_relative`` is set (config-file entries), else
9
+ verbatim (programmatic entries).
10
+ 2. **Bare name** -- a same-named local file under ``base`` *shadows* the
11
+ package; otherwise the package dir is located via
12
+ :func:`importlib.util.find_spec` and the current platform's loadable is
13
+ globbed from inside it. Zero matches and multiple matches are both hard
14
+ errors -- the caller must disambiguate with a literal path.
15
+ """
16
+
17
+ import glob as _glob
18
+ import importlib.util
19
+ import os
20
+ import sys
21
+
22
+ # Suffixes that mark a value as "already a file path", so package resolution
23
+ # is never attempted.
24
+ _LOADABLE_SUFFIXES = (".so", ".dylib", ".dll", ".pyd")
25
+
26
+
27
+ def _platform_patterns():
28
+ """Loadable-file glob(s) for the current platform."""
29
+ if sys.platform == "darwin":
30
+ return ("*.dylib",)
31
+ if sys.platform == "win32":
32
+ return ("*.dll", "*.pyd")
33
+ return ("*.so",)
34
+
35
+
36
+ def is_bare_name(path):
37
+ """True when ``path`` is a bare package name rather than a file path."""
38
+ if os.sep in path or (os.altsep and os.altsep in path):
39
+ return False
40
+ return not path.endswith(_LOADABLE_SUFFIXES)
41
+
42
+
43
+ def _resolve_package(name):
44
+ """Locate ``name``'s package dir and glob its platform loadable file."""
45
+ try:
46
+ spec = importlib.util.find_spec(name)
47
+ except (ImportError, ValueError) as exc:
48
+ raise ValueError(
49
+ f"could not resolve extension package {name!r}: {exc}"
50
+ ) from exc
51
+ if spec is None:
52
+ raise ValueError(f"could not resolve extension package {name!r}: not installed")
53
+
54
+ dirs = list(spec.submodule_search_locations or [])
55
+ if not dirs and spec.origin and spec.origin not in ("built-in", "frozen"):
56
+ dirs.append(os.path.dirname(spec.origin))
57
+ if not dirs:
58
+ raise ValueError(
59
+ f"could not resolve extension package {name!r}: no package directory"
60
+ )
61
+
62
+ patterns = _platform_patterns()
63
+ matches = set()
64
+ for d in dirs:
65
+ for pat in patterns:
66
+ matches.update(_glob.glob(os.path.join(d, "**", pat), recursive=True))
67
+ found = sorted(matches)
68
+
69
+ try:
70
+ (single,) = found
71
+ except ValueError:
72
+ pat_desc = " / ".join(patterns)
73
+ if not found:
74
+ raise ValueError(
75
+ f"no loadable extension file ({pat_desc}) found in package "
76
+ f"{name!r} (searched {', '.join(dirs)})"
77
+ ) from None
78
+ raise ValueError(
79
+ f"multiple loadable extension files found in package {name!r}: "
80
+ f"{', '.join(found)}; disambiguate with a literal path"
81
+ ) from None
82
+ return single
83
+
84
+
85
+ def resolve_extension_path(path, base, resolve_relative):
86
+ """Resolve an extension ``path`` to a concrete file.
87
+
88
+ ``base`` is the directory a relative path and the bare-name shadow probe
89
+ resolve against (a config file's parent dir, or the cwd for programmatic
90
+ entries). ``resolve_relative`` makes a relative path-looking value absolute
91
+ against ``base`` (config-file semantics); when false it is returned verbatim
92
+ (programmatic semantics).
93
+ """
94
+ if not is_bare_name(path):
95
+ if resolve_relative and not os.path.isabs(path):
96
+ return os.path.join(base, path)
97
+ return path
98
+ local = os.path.join(base, path)
99
+ if os.path.isfile(local):
100
+ return local
101
+ return _resolve_package(path)