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 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]
@@ -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,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