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 +9 -0
- dirsql/_async.py +184 -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 +20 -0
- dirsql/cli/is_windows.py +9 -0
- dirsql/cli/main.py +38 -0
- dirsql/cli/resolve_config_extensions.py +55 -0
- dirsql/py.typed +0 -0
- dirsql/resolve_config_extensions.py +67 -0
- dirsql/resolve_extension.py +101 -0
- dirsql-0.3.82.dist-info/METADATA +134 -0
- dirsql-0.3.82.dist-info/RECORD +18 -0
- dirsql-0.3.82.dist-info/WHEEL +4 -0
- dirsql-0.3.82.dist-info/entry_points.txt +2 -0
- dirsql-0.3.82.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,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)
|
dirsql/cli/is_windows.py
ADDED
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)
|