Skip to content

Commit b7beddb

Browse files
authored
feat(editable): expose search paths on redirect loaders (#1567)
* feat(editable): expose search paths on redirect loaders Add a public `paths` attribute to every loader the editable finders hand out, listing the package's search locations (its `__path__` entries) so a package can find the CMake install tree at runtime. Add a matching read-only `paths` property to `_SkbuildMultiplexedPath`, mirroring python/importlib_resources#310. Addresses the second part of #1565. Assisted-by: ClaudeCode:claude-fable-5-1 * Apply suggestion from @henryiii * docs: use pathlib over os.path in editable docs Assisted-by: opencode:NRP/deepseek-v4-flash
1 parent 5377cd8 commit b7beddb

4 files changed

Lines changed: 174 additions & 11 deletions

File tree

‎docs/configuration/editable.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,26 @@ Manual `__loader__.rebuild()` for redirect installs, and both manual and
154154
automatic (`editable.rebuild`) rebuilds for inplace installs.
155155

156156
:::
157+
158+
## Locating the install tree
159+
160+
The same loader exposes `paths`, the package's search locations in `__path__`
161+
order: the CMake install tree and the source tree. This gives a package a
162+
supported way to find the directory that holds its CMake outputs at runtime,
163+
which `importlib.resources.files()` does not provide when a directory exists in
164+
both trees:
165+
166+
```python
167+
from pathlib import Path
168+
169+
source_tree = Path(some_package.__file__).parent
170+
install_tree = next(p for p in some_package.__loader__.paths if p != source_tree)
171+
```
172+
173+
A plain module has no search locations, so its `paths` is empty.
174+
175+
:::{versionadded} 1.1
176+
177+
`__loader__.paths`.
178+
179+
:::

‎src/scikit_build_core/resources/_editable_redirect.py‎

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
TYPE_CHECKING = False
1212
if TYPE_CHECKING:
1313
import importlib.machinery
14+
from pathlib import Path
1415

1516
DIR = os.path.abspath(os.path.dirname(__file__))
1617
MARKER = "SKBUILD_EDITABLE_SKIP"
@@ -96,6 +97,11 @@ def __init__(self, *paths: str) -> None:
9697

9798
self._paths = [Path(p) for p in paths if os.path.isdir(p)]
9899

100+
@property
101+
def paths(self) -> list[Path]:
102+
"""The merged directories, in search order (python/importlib_resources#310)."""
103+
return list(self._paths)
104+
99105
@property
100106
def name(self) -> str:
101107
return self._paths[0].name if self._paths else ""
@@ -210,15 +216,21 @@ class _ScikitBuildLoaderWrapper:
210216
``rebuild()`` method that runs the same CMake build/install the import-time
211217
auto-rebuild uses. This lets a user trigger a rebuild explicitly via
212218
``module.__loader__.rebuild()`` without enabling ``editable.rebuild``.
219+
220+
``paths`` lists the package's search locations (its ``__path__`` entries, in
221+
``__path__`` order), so a package can locate the CMake install tree at
222+
runtime; it is empty for a plain module.
213223
"""
214224

215225
def __init__(
216226
self,
217227
loader: object,
218228
finder: ScikitBuildRedirectingFinder | ScikitBuildInplaceFinder,
229+
search_paths: list[str] | None = None,
219230
) -> None:
220231
self._skbuild_loader = loader
221232
self._skbuild_finder = finder
233+
self.paths: list[str] = list(search_paths or [])
222234

223235
def __getattr__(self, name: str) -> object:
224236
return getattr(self._skbuild_loader, name)
@@ -242,11 +254,10 @@ def __init__(
242254
finder: ScikitBuildRedirectingFinder,
243255
search_paths: list[str],
244256
) -> None:
245-
super().__init__(loader, finder)
246-
self._skbuild_paths = search_paths
257+
super().__init__(loader, finder, search_paths)
247258

248259
def get_resource_reader(self, module_name: str) -> _ScikitBuildEditableReader:
249-
return _ScikitBuildEditableReader(self._skbuild_paths)
260+
return _ScikitBuildEditableReader(self.paths)
250261

251262

252263
class _ScikitBuildNamespaceLoader:
@@ -265,7 +276,7 @@ class _ScikitBuildNamespaceLoader:
265276
def __init__(
266277
self, search_paths: list[str], finder: ScikitBuildRedirectingFinder
267278
) -> None:
268-
self._skbuild_paths = search_paths
279+
self.paths: list[str] = list(search_paths)
269280
self._skbuild_finder = finder
270281

271282
def create_module(self, spec: object) -> object:
@@ -275,7 +286,7 @@ def exec_module(self, module: object) -> None:
275286
return None
276287

277288
def get_resource_reader(self, module_name: str) -> _ScikitBuildEditableReader:
278-
return _ScikitBuildEditableReader(self._skbuild_paths)
289+
return _ScikitBuildEditableReader(self.paths)
279290

280291
def rebuild(self) -> None:
281292
self._skbuild_finder.rebuild()
@@ -571,10 +582,11 @@ def _make_spec(
571582
else None,
572583
)
573584
# Wrap the loader so it exposes a rebuild() hook (reachable as
574-
# module.__loader__.rebuild()). Packages with more than one search
575-
# location (e.g. a source tree and a CMake install tree) additionally get
576-
# a resource reader so importlib.resources.files() can see resources from
577-
# every location, not just origin's directory.
585+
# module.__loader__.rebuild()) and the package's search locations as
586+
# .paths. Packages with more than one search location (e.g. a source
587+
# tree and a CMake install tree) additionally get a resource reader so
588+
# importlib.resources.files() can see resources from every location, not
589+
# just origin's directory.
578590
if spec is not None and spec.loader is not None:
579591
if (
580592
is_pkg
@@ -585,7 +597,9 @@ def _make_spec(
585597
spec.loader, self, submodule_search_locations
586598
)
587599
else:
588-
spec.loader = _ScikitBuildLoaderWrapper(spec.loader, self) # type: ignore[assignment]
600+
spec.loader = _ScikitBuildLoaderWrapper( # type: ignore[assignment]
601+
spec.loader, self, submodule_search_locations if is_pkg else None
602+
)
589603
return spec
590604

591605
def rebuild(self) -> None:
@@ -666,7 +680,9 @@ def find_spec(
666680
# locations beyond our search paths.
667681
if spec is None or spec.loader is None:
668682
return None
669-
spec.loader = _ScikitBuildLoaderWrapper(spec.loader, self) # type: ignore[assignment]
683+
spec.loader = _ScikitBuildLoaderWrapper( # type: ignore[assignment]
684+
spec.loader, self, spec.submodule_search_locations
685+
)
670686
return spec
671687

672688
def rebuild(self) -> None:

‎tests/test_editable.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,11 @@ def test_direct_import(editable, isolated):
242242
"import pkg; print(callable(getattr(pkg.__loader__, 'rebuild', None)))"
243243
)
244244
assert out.splitlines()[-1] == "True"
245+
# The loader also lists the package's search locations (#1565).
246+
out = isolated.execute(
247+
"import pkg; print(pkg.__loader__.paths == list(pkg.__path__))"
248+
)
249+
assert out.splitlines()[-1] == "True"
245250

246251

247252
@pytest.mark.compile

‎tests/test_editable_redirect.py‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,125 @@ def fake_rebuild() -> None:
441441
assert calls == 3
442442

443443

444+
def test_loader_exposes_paths(tmp_path: Path):
445+
"""Every loader the finder hands out exposes .paths (module.__loader__.paths).
446+
447+
paths lists the package's search locations in __path__ order (the CMake
448+
install tree and the source tree), so a package can locate its install tree
449+
at runtime without importing scikit-build-core (#1565). A plain module has
450+
no search locations, so its paths is empty.
451+
"""
452+
import importlib.machinery
453+
454+
src_pkg = tmp_path / "src" / "pkg"
455+
src_pkg.mkdir(parents=True)
456+
init = src_pkg / "__init__.py"
457+
init.touch()
458+
(src_pkg / "mod.py").touch()
459+
site = tmp_path / "site-packages"
460+
wheel_pkg = site / "pkg"
461+
wheel_pkg.mkdir(parents=True)
462+
ext = importlib.machinery.EXTENSION_SUFFIXES[0]
463+
(wheel_pkg / f"_ext{ext}").touch()
464+
465+
single_pkg = tmp_path / "src" / "single"
466+
single_pkg.mkdir()
467+
(single_pkg / "__init__.py").touch()
468+
469+
ns_dir = tmp_path / "src" / "ns"
470+
ns_dir.mkdir()
471+
472+
finder = ScikitBuildRedirectingFinder(
473+
known_source_files={
474+
"pkg": str(init),
475+
"pkg.mod": str(src_pkg / "mod.py"),
476+
"single": str(single_pkg / "__init__.py"),
477+
},
478+
known_wheel_files={"pkg._ext": f"pkg/_ext{ext}"},
479+
known_directories={
480+
"pkg": [str(src_pkg), "pkg"],
481+
"single": [str(single_pkg)],
482+
"ns": [str(ns_dir)],
483+
},
484+
known_packages=["pkg", "single"],
485+
path=None,
486+
rebuild=False,
487+
verbose=False,
488+
build_options=[],
489+
install_options=[],
490+
dir=str(site),
491+
install_dir="",
492+
)
493+
494+
# Two search locations: paths mirrors __path__ exactly.
495+
pkg_spec = finder.find_spec("pkg")
496+
assert pkg_spec is not None
497+
assert pkg_spec.submodule_search_locations is not None
498+
paths = pkg_spec.loader.paths # type: ignore[union-attr]
499+
assert paths == list(pkg_spec.submodule_search_locations)
500+
assert set(paths) == {str(src_pkg), str(wheel_pkg)}
501+
# A copy: mutating it must not change the finder's state or __path__.
502+
expected = list(pkg_spec.submodule_search_locations)
503+
paths.clear()
504+
assert list(pkg_spec.submodule_search_locations) == expected
505+
assert finder.find_spec("pkg").loader.paths == expected # type: ignore[union-attr]
506+
# Delegation to the wrapped loader still works.
507+
assert pkg_spec.loader.get_filename("pkg") == str(init) # type: ignore[union-attr]
508+
509+
# Single search location.
510+
single_spec = finder.find_spec("single")
511+
assert single_spec is not None
512+
assert single_spec.loader.paths == [str(single_pkg)] # type: ignore[union-attr]
513+
514+
# Plain modules (source and compiled) have no search locations.
515+
for name in ("pkg.mod", "pkg._ext"):
516+
spec = finder.find_spec(name)
517+
assert spec is not None
518+
assert spec.loader.paths == [] # type: ignore[union-attr]
519+
520+
# Namespace package.
521+
ns_spec = finder.find_spec("ns")
522+
assert ns_spec is not None
523+
assert ns_spec.loader.paths == [str(ns_dir)] # type: ignore[union-attr]
524+
525+
526+
def test_inplace_loader_exposes_paths(tmp_path: Path):
527+
"""The inplace finder's loaders expose .paths too, mirroring rebuild()."""
528+
pkg = tmp_path / "pkg"
529+
pkg.mkdir()
530+
(pkg / "__init__.py").touch()
531+
(pkg / "mod.py").touch()
532+
533+
finder = ScikitBuildInplaceFinder(
534+
known_packages=["pkg"],
535+
search_paths=[str(tmp_path)],
536+
path=None,
537+
rebuild=False,
538+
verbose=False,
539+
build_options=[],
540+
)
541+
542+
pkg_spec = finder.find_spec("pkg")
543+
assert pkg_spec is not None
544+
assert pkg_spec.loader.paths == [str(pkg)] # type: ignore[union-attr]
545+
mod_spec = finder.find_spec("pkg.mod", [str(pkg)])
546+
assert mod_spec is not None
547+
assert mod_spec.loader.paths == [] # type: ignore[union-attr]
548+
549+
550+
def test_multiplexed_path_paths(tmp_path: Path):
551+
"""_SkbuildMultiplexedPath.paths lists the merged directories in order."""
552+
a = tmp_path / "a"
553+
b = tmp_path / "b"
554+
a.mkdir()
555+
b.mkdir()
556+
557+
mp = _editable_redirect._SkbuildMultiplexedPath(str(a), str(b), str(tmp_path / "x"))
558+
assert mp.paths == [a, b]
559+
mp.paths.clear()
560+
assert mp.paths == [a, b]
561+
562+
444563
def test_loader_rebuild_without_build_dir_errors(tmp_path: Path):
445564
"""rebuild() errors when there is no build dir to rebuild.
446565

0 commit comments

Comments
 (0)