dirsql 0.4.1__cp310-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 +9 -0
- dirsql/_async.py +194 -0
- dirsql/_binary/dirsql.exe +0 -0
- dirsql/_dirsql.pyd +0 -0
- dirsql/_dirsql.pyi +76 -0
- dirsql/cli/__init__.py +0 -0
- dirsql/cli/binary_path.py +23 -0
- dirsql/cli/discover_plugins/__init__.py +0 -0
- dirsql/cli/discover_plugins/discovered_fragments.py +18 -0
- dirsql/cli/discover_plugins/discovery_disabled.py +14 -0
- dirsql/cli/discover_plugins/fragment_path.py +25 -0
- dirsql/cli/discover_plugins/user_passed_config.py +15 -0
- dirsql/cli/discover_plugins/with_discovered_plugins.py +48 -0
- dirsql/cli/is_windows.py +9 -0
- dirsql/cli/main.py +42 -0
- dirsql/cli/resolve_config_extensions.py +55 -0
- dirsql/py.typed +0 -0
- dirsql/resolve_config_extensions.py +126 -0
- dirsql/resolve_extension.py +101 -0
- dirsql-0.4.1.dist-info/METADATA +135 -0
- dirsql-0.4.1.dist-info/RECORD +24 -0
- dirsql-0.4.1.dist-info/WHEEL +4 -0
- dirsql-0.4.1.dist-info/entry_points.txt +2 -0
- dirsql-0.4.1.dist-info/sboms/dirsql-py-ext.cyclonedx.json +3017 -0
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,194 @@
|
|
|
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_configs_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
|
+
The index root is the explicit ``root`` when given, else the process
|
|
57
|
+
current working directory. A ``config`` file's location never sets the
|
|
58
|
+
root -- it only supplies tables, ignore patterns, and extensions. There
|
|
59
|
+
is no ``[dirsql].root`` config key. Constructing with neither ``root``
|
|
60
|
+
nor ``config`` roots at the cwd (no error is raised).
|
|
61
|
+
|
|
62
|
+
Pass ``persist=True`` to keep an on-disk SQLite cache (default location:
|
|
63
|
+
``<root>/.dirsql/cache.db``). Override the location with ``persist_path``.
|
|
64
|
+
|
|
65
|
+
Pass ``extensions`` -- a list of ``{"path": ..., "entrypoint": ...}`` dicts
|
|
66
|
+
(``entrypoint`` optional) -- to load SQLite extensions onto the connection
|
|
67
|
+
at startup. Any ``[[dirsql.extension]]`` entries in a ``config`` file are
|
|
68
|
+
appended after the programmatic ones. A ``path`` (programmatic or
|
|
69
|
+
config-file) may be a bare **package name**, resolved from the installed
|
|
70
|
+
package in the runtime env.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
root=None,
|
|
76
|
+
*,
|
|
77
|
+
tables=None,
|
|
78
|
+
ignore=None,
|
|
79
|
+
config=None,
|
|
80
|
+
persist=False,
|
|
81
|
+
persist_path=None,
|
|
82
|
+
extensions=None,
|
|
83
|
+
):
|
|
84
|
+
self._root = root
|
|
85
|
+
self._tables = tables
|
|
86
|
+
self._ignore = ignore
|
|
87
|
+
self._config = config
|
|
88
|
+
# A single path or a list of paths; the list merges in order (each
|
|
89
|
+
# config's [[table]] / ignore / [[dirsql.extension]] accumulate).
|
|
90
|
+
self._config_paths = (
|
|
91
|
+
[]
|
|
92
|
+
if config is None
|
|
93
|
+
else [config]
|
|
94
|
+
if isinstance(config, str)
|
|
95
|
+
else list(config)
|
|
96
|
+
)
|
|
97
|
+
self._persist = persist
|
|
98
|
+
self._persist_path = persist_path
|
|
99
|
+
self._extensions = extensions
|
|
100
|
+
self._db = None
|
|
101
|
+
self._ready_event = asyncio.Event()
|
|
102
|
+
self._init_error = None
|
|
103
|
+
self._task = asyncio.ensure_future(self._init_bg())
|
|
104
|
+
|
|
105
|
+
async def _init_bg(self):
|
|
106
|
+
"""Run the scan in the background."""
|
|
107
|
+
try:
|
|
108
|
+
self._db = await asyncio.to_thread(self._build_db)
|
|
109
|
+
except Exception as exc:
|
|
110
|
+
self._init_error = exc
|
|
111
|
+
finally:
|
|
112
|
+
self._ready_event.set()
|
|
113
|
+
|
|
114
|
+
def _build_db(self):
|
|
115
|
+
"""Resolve extensions and construct the Rust-backed instance.
|
|
116
|
+
|
|
117
|
+
Runs on a worker thread (via ``asyncio.to_thread``): both the
|
|
118
|
+
package-name resolution and the core's initial scan are blocking.
|
|
119
|
+
|
|
120
|
+
When the ``config`` file names an extension by bare package name, the
|
|
121
|
+
SDK resolves every one of the config's ``[[dirsql.extension]]`` entries
|
|
122
|
+
itself -- appended after the programmatic ones -- and suppresses the
|
|
123
|
+
core's own config-extension loading so the entries are not loaded a
|
|
124
|
+
second time (the core cannot resolve a bare name).
|
|
125
|
+
"""
|
|
126
|
+
extensions = self._resolved_extensions()
|
|
127
|
+
suppress = False
|
|
128
|
+
if self._config_paths:
|
|
129
|
+
config_extensions = resolve_configs_extension_specs(self._config_paths)
|
|
130
|
+
if config_extensions is not None:
|
|
131
|
+
extensions = [*(extensions or []), *config_extensions]
|
|
132
|
+
suppress = True
|
|
133
|
+
return _RustDirSQL(
|
|
134
|
+
self._root,
|
|
135
|
+
tables=self._tables,
|
|
136
|
+
ignore=self._ignore,
|
|
137
|
+
config=self._config_paths or None,
|
|
138
|
+
persist=self._persist,
|
|
139
|
+
persist_path=self._persist_path,
|
|
140
|
+
extensions=extensions,
|
|
141
|
+
suppress_config_extensions=suppress,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def _resolved_extensions(self):
|
|
145
|
+
"""Resolve each programmatic extension's ``path`` to a loadable file.
|
|
146
|
+
|
|
147
|
+
A bare package name is resolved to the loadable installed in the
|
|
148
|
+
runtime env; path-looking values pass through verbatim. Config-file
|
|
149
|
+
``[[dirsql.extension]]`` entries are handled by ``_build_db``.
|
|
150
|
+
"""
|
|
151
|
+
if not self._extensions:
|
|
152
|
+
return self._extensions
|
|
153
|
+
return [
|
|
154
|
+
{
|
|
155
|
+
"path": resolve_extension_path(
|
|
156
|
+
e["path"], base=os.getcwd(), resolve_relative=False
|
|
157
|
+
),
|
|
158
|
+
"entrypoint": e.get("entrypoint"),
|
|
159
|
+
}
|
|
160
|
+
for e in self._extensions
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
async def ready(self):
|
|
164
|
+
"""Wait until the initial scan is complete.
|
|
165
|
+
|
|
166
|
+
Raises any exception that occurred during init.
|
|
167
|
+
Can be called multiple times safely.
|
|
168
|
+
"""
|
|
169
|
+
await self._ready_event.wait()
|
|
170
|
+
if self._init_error is not None:
|
|
171
|
+
raise self._init_error
|
|
172
|
+
|
|
173
|
+
async def query(self, sql):
|
|
174
|
+
"""Execute a SQL query asynchronously.
|
|
175
|
+
|
|
176
|
+
Awaits :meth:`ready` first, so calling ``query`` before an explicit
|
|
177
|
+
``await db.ready()`` waits for the background scan (and re-raises any
|
|
178
|
+
initialization error) instead of failing on a still-``None`` ``_db``.
|
|
179
|
+
"""
|
|
180
|
+
await self.ready()
|
|
181
|
+
db = self._db
|
|
182
|
+
assert db is not None # ready() returned, so _init_bg set _db
|
|
183
|
+
return await asyncio.to_thread(db.query, sql)
|
|
184
|
+
|
|
185
|
+
def watch(self):
|
|
186
|
+
"""Start watching for file changes. Returns an async iterable of RowEvent.
|
|
187
|
+
|
|
188
|
+
Like :meth:`query`, the returned stream awaits :meth:`ready` on its
|
|
189
|
+
first iteration before starting the watcher, so calling ``watch``
|
|
190
|
+
before an explicit ``await db.ready()`` waits for the background scan
|
|
191
|
+
(and surfaces any initialization error) instead of failing on a
|
|
192
|
+
still-``None`` ``_db``.
|
|
193
|
+
"""
|
|
194
|
+
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, TypedDict
|
|
15
|
+
|
|
16
|
+
from typing_extensions import NotRequired, 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
|
+
on_file: 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: list[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,23 @@
|
|
|
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
|
+
# Chained rather than `joinpath("_binary", name)`: multi-segment joinpath
|
|
13
|
+
# is 3.11+, and `files()` returns a bare `Traversable` for non-filesystem
|
|
14
|
+
# loaders.
|
|
15
|
+
path = files("dirsql").joinpath("_binary").joinpath(name)
|
|
16
|
+
if not path.is_file():
|
|
17
|
+
raise FileNotFoundError(
|
|
18
|
+
f"bundled `{name}` not found at {path}. The dirsql PyPI wheel "
|
|
19
|
+
"no longer ships the CLI binary (release-tooling regression "
|
|
20
|
+
"while putitoutthere wires up bundle_cli). Install the CLI via "
|
|
21
|
+
"`cargo install dirsql --features cli` or `npx dirsql`."
|
|
22
|
+
)
|
|
23
|
+
return str(path)
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Discover installed plugins' fragment paths, ordered by entry-point name."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import metadata
|
|
6
|
+
|
|
7
|
+
from .fragment_path import fragment_path
|
|
8
|
+
|
|
9
|
+
ENTRY_POINT_GROUP = "dirsql"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def discovered_fragments() -> list[str]:
|
|
13
|
+
"""Fragment paths for every installed plugin, ordered by entry-point name
|
|
14
|
+
(deterministic, so a running server's ``-c`` list is reproducible)."""
|
|
15
|
+
entry_points = sorted(
|
|
16
|
+
metadata.entry_points(group=ENTRY_POINT_GROUP), key=lambda ep: ep.name
|
|
17
|
+
)
|
|
18
|
+
return [fragment_path(ep.value) for ep in entry_points]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Whether plugin discovery is opted out (flag or env var)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
NO_PLUGIN_FLAG = "--no-plugin"
|
|
8
|
+
NO_PLUGIN_ENV = "DIRSQL_NO_PLUGIN"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def discovery_disabled(argv: list[str]) -> bool:
|
|
12
|
+
"""True when discovery is opted out via ``--no-plugin`` or
|
|
13
|
+
``DIRSQL_NO_PLUGIN``."""
|
|
14
|
+
return NO_PLUGIN_FLAG in argv or bool(os.environ.get(NO_PLUGIN_ENV))
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Resolve a plugin module's shipped ``dirsql.toml`` fragment path."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import resources
|
|
6
|
+
|
|
7
|
+
FRAGMENT_NAME = "dirsql.toml"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def fragment_path(module_name: str) -> str:
|
|
11
|
+
"""Absolute path to a plugin module's shipped ``dirsql.toml``. Raises a
|
|
12
|
+
clear error naming the plugin when the module or the fragment is missing --
|
|
13
|
+
never a silent skip."""
|
|
14
|
+
try:
|
|
15
|
+
fragment = resources.files(module_name).joinpath(FRAGMENT_NAME)
|
|
16
|
+
except ModuleNotFoundError as exc:
|
|
17
|
+
raise ValueError(
|
|
18
|
+
f"dirsql plugin module {module_name!r} is not importable: {exc}"
|
|
19
|
+
) from exc
|
|
20
|
+
if not fragment.is_file():
|
|
21
|
+
raise ValueError(
|
|
22
|
+
f"dirsql plugin {module_name!r} ships no {FRAGMENT_NAME} fragment "
|
|
23
|
+
f"(expected at {fragment})"
|
|
24
|
+
)
|
|
25
|
+
return str(fragment)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Whether the user's argv already names a config file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def user_passed_config(argv: list[str]) -> bool:
|
|
7
|
+
"""True when argv already names a ``-c`` / ``--config`` file -- the user's
|
|
8
|
+
own config is the base, so the baked-in default is not re-added."""
|
|
9
|
+
for arg in argv:
|
|
10
|
+
# `--config` naturally fails `startswith("-c")` (it starts with `--`),
|
|
11
|
+
# so the three clauses are disjoint: bare/attached short `-c`, long
|
|
12
|
+
# `--config`, and the `--config=<value>` form.
|
|
13
|
+
if arg == "--config" or arg.startswith("--config=") or arg.startswith("-c"):
|
|
14
|
+
return True
|
|
15
|
+
return False
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Rewrite argv to activate installed plugins (the discovery orchestrator).
|
|
2
|
+
|
|
3
|
+
This is the public entry point of the ``discover_plugins`` package (installed =
|
|
4
|
+
active, CLI only; #363/#529). A plugin is an ordinary Python package that
|
|
5
|
+
declares ``[project.entry-points.dirsql]`` naming its top-level module and ships
|
|
6
|
+
a ``dirsql.toml`` fragment there; when installed alongside ``dirsql``, the
|
|
7
|
+
``pip``/``uvx`` launcher discovers it and injects the fragment as a ``-c`` flag
|
|
8
|
+
plus the hidden ``--include-default`` (#604) when the user gave no ``-c``.
|
|
9
|
+
Opt out via ``--no-plugin`` / ``DIRSQL_NO_PLUGIN=1``. The compiled binary knows
|
|
10
|
+
nothing about plugins, and the SDK never discovers -- only this CLI launcher.
|
|
11
|
+
The helpers each live in their own module (``user_passed_config``,
|
|
12
|
+
``discovery_disabled``, ``fragment_path``, ``discovered_fragments``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from .discovered_fragments import discovered_fragments
|
|
18
|
+
from .discovery_disabled import NO_PLUGIN_FLAG, discovery_disabled
|
|
19
|
+
from .user_passed_config import user_passed_config
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def with_discovered_plugins(argv: list[str]) -> list[str]:
|
|
23
|
+
"""Return ``argv`` with each installed plugin's fragment appended as ``-c``
|
|
24
|
+
(plus ``--include-default`` when the user passed no ``-c``). ``--no-plugin``
|
|
25
|
+
/ ``DIRSQL_NO_PLUGIN`` skip discovery, consuming the flag. ``init`` takes no
|
|
26
|
+
config, so it is left untouched. Raises if a declared plugin is missing its
|
|
27
|
+
module or fragment (the launcher surfaces a clean error).
|
|
28
|
+
|
|
29
|
+
Appending is safe because config flags are subcommand-local (#609): the
|
|
30
|
+
user's own ``-c`` sits after the ``query`` subcommand (or at top level in
|
|
31
|
+
server mode), so the injected flags land in the same clap context and
|
|
32
|
+
accumulate with it -- plugins merge after the user's config, preserving
|
|
33
|
+
user-first order.
|
|
34
|
+
"""
|
|
35
|
+
if discovery_disabled(argv):
|
|
36
|
+
return [a for a in argv if a != NO_PLUGIN_FLAG]
|
|
37
|
+
if argv and argv[0] == "init":
|
|
38
|
+
return argv
|
|
39
|
+
fragments = discovered_fragments()
|
|
40
|
+
if not fragments:
|
|
41
|
+
return argv
|
|
42
|
+
injected: list[str] = []
|
|
43
|
+
if not user_passed_config(argv):
|
|
44
|
+
injected.append("--include-default")
|
|
45
|
+
for fragment in fragments:
|
|
46
|
+
injected.append("-c")
|
|
47
|
+
injected.append(fragment)
|
|
48
|
+
return [*argv, *injected]
|
dirsql/cli/is_windows.py
ADDED
dirsql/cli/main.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
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 .discover_plugins.with_discovered_plugins import with_discovered_plugins
|
|
13
|
+
from .is_windows import is_windows
|
|
14
|
+
from .resolve_config_extensions import with_resolved_extensions
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(argv: list[str] | None = None) -> int:
|
|
18
|
+
if argv is None:
|
|
19
|
+
argv = sys.argv[1:]
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
binary = binary_path()
|
|
23
|
+
except FileNotFoundError as exc:
|
|
24
|
+
print(f"dirsql: {exc}", file=sys.stderr)
|
|
25
|
+
return 1
|
|
26
|
+
|
|
27
|
+
# Discover installed plugins (CLI only) and inject their config fragments as
|
|
28
|
+
# `-c` flags before resolving extensions; then resolve any package-name
|
|
29
|
+
# extensions in a TOML config here (the binary can't) as `--extension`
|
|
30
|
+
# flags. Both are no-ops when nothing applies.
|
|
31
|
+
try:
|
|
32
|
+
argv = with_discovered_plugins(argv)
|
|
33
|
+
argv = with_resolved_extensions(argv)
|
|
34
|
+
except Exception as exc:
|
|
35
|
+
print(f"dirsql: {exc}", file=sys.stderr)
|
|
36
|
+
return 1
|
|
37
|
+
|
|
38
|
+
if is_windows():
|
|
39
|
+
completed = subprocess.run([binary, *argv])
|
|
40
|
+
return completed.returncode
|
|
41
|
+
os.execv(binary, [binary, *argv])
|
|
42
|
+
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,126 @@
|
|
|
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 sys
|
|
18
|
+
from importlib import import_module
|
|
19
|
+
|
|
20
|
+
from .resolve_extension import is_bare_name, resolve_extension_path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load_toml_module():
|
|
24
|
+
"""Return the TOML parser module for the running interpreter.
|
|
25
|
+
|
|
26
|
+
``tomllib`` is stdlib only on 3.11+; on 3.10 the ``tomli`` backport it was
|
|
27
|
+
derived from (a version-gated dependency) provides the same surface.
|
|
28
|
+
Imported by name so a unit test can exercise both arms on any
|
|
29
|
+
interpreter -- a literal ``import tomllib`` is unreachable on 3.10 no
|
|
30
|
+
matter what ``sys.version_info`` claims.
|
|
31
|
+
"""
|
|
32
|
+
if sys.version_info >= (3, 11):
|
|
33
|
+
return import_module("tomllib")
|
|
34
|
+
return import_module("tomli")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_toml = _load_toml_module()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _load_extension_entries(config_path):
|
|
41
|
+
"""Return ``(entries, base_dir)`` for a config's ``[[dirsql.extension]]``.
|
|
42
|
+
|
|
43
|
+
``None`` when the config is missing, unreadable/malformed, or declares no
|
|
44
|
+
extension array -- the caller should leave such configs to the core.
|
|
45
|
+
"""
|
|
46
|
+
if not os.path.isfile(config_path):
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
with open(config_path, "rb") as f:
|
|
50
|
+
doc = _toml.load(f)
|
|
51
|
+
except (OSError, _toml.TOMLDecodeError):
|
|
52
|
+
# Leave a malformed / unreadable config for the core to report.
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
entries = (doc.get("dirsql") or {}).get("extension")
|
|
56
|
+
if not isinstance(entries, list):
|
|
57
|
+
return None
|
|
58
|
+
return entries, os.path.dirname(os.path.abspath(config_path))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _has_bare_name(entries):
|
|
62
|
+
return any(
|
|
63
|
+
isinstance(e, dict)
|
|
64
|
+
and isinstance(e.get("path"), str)
|
|
65
|
+
and is_bare_name(e["path"])
|
|
66
|
+
for e in entries
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _resolve_entries(entries, base):
|
|
71
|
+
specs = []
|
|
72
|
+
for e in entries:
|
|
73
|
+
entrypoint = e.get("entrypoint")
|
|
74
|
+
specs.append(
|
|
75
|
+
{
|
|
76
|
+
"path": resolve_extension_path(
|
|
77
|
+
e["path"], base=base, resolve_relative=True
|
|
78
|
+
),
|
|
79
|
+
"entrypoint": entrypoint if isinstance(entrypoint, str) else None,
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
return specs
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def resolve_config_extension_specs(config_path):
|
|
86
|
+
"""Resolve a TOML config's ``[[dirsql.extension]]`` entries to literal paths.
|
|
87
|
+
|
|
88
|
+
Returns a list of ``{"path", "entrypoint"}`` dicts -- every entry resolved
|
|
89
|
+
via :func:`resolve_extension_path` against the config file's parent
|
|
90
|
+
directory -- when at least one entry's ``path`` is a bare package name.
|
|
91
|
+
Returns ``None`` when the caller should not intervene: the config is
|
|
92
|
+
missing, malformed, declares no extensions, or uses only literal paths --
|
|
93
|
+
leaving the core's own loading (and error reporting) untouched. Raises if
|
|
94
|
+
a package name cannot be resolved.
|
|
95
|
+
"""
|
|
96
|
+
loaded = _load_extension_entries(config_path)
|
|
97
|
+
if loaded is None:
|
|
98
|
+
return None
|
|
99
|
+
entries, base = loaded
|
|
100
|
+
if not _has_bare_name(entries):
|
|
101
|
+
return None
|
|
102
|
+
return _resolve_entries(entries, base)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def resolve_configs_extension_specs(config_paths):
|
|
106
|
+
"""Resolve the ``[[dirsql.extension]]`` entries of several configs, in order.
|
|
107
|
+
|
|
108
|
+
The SDK intervenes for the whole set only when **some** config names an
|
|
109
|
+
extension by bare package name (the core can resolve neither package names
|
|
110
|
+
nor -- once globally suppressed -- the literal entries of the other
|
|
111
|
+
configs). When it intervenes it resolves **every** config's entries, each
|
|
112
|
+
against that config's own parent directory, concatenated in ``config_paths``
|
|
113
|
+
order; the caller suppresses the core's config-extension loading and passes
|
|
114
|
+
the resolved list. Returns ``None`` when no config uses a package name,
|
|
115
|
+
leaving every config's loading to the core.
|
|
116
|
+
"""
|
|
117
|
+
loaded = [_load_extension_entries(p) for p in config_paths]
|
|
118
|
+
if not any(item is not None and _has_bare_name(item[0]) for item in loaded):
|
|
119
|
+
return None
|
|
120
|
+
specs = []
|
|
121
|
+
for item in loaded:
|
|
122
|
+
if item is None:
|
|
123
|
+
continue
|
|
124
|
+
entries, base = item
|
|
125
|
+
specs.extend(_resolve_entries(entries, base))
|
|
126
|
+
return specs
|