codekingpro/portable-devtools
115k
1"""2Create a wheel that, when installed, will make the source package 'editable'3(add it to the interpreter's path, including metadata) per PEP 660. Replaces4'setup.py develop'.5 6.. note::7 One of the mechanisms briefly mentioned in PEP 660 to implement editable installs is8 to create a separated directory inside ``build`` and use a .pth file to point to that9 directory. In the context of this file such directory is referred as10 *auxiliary build directory* or ``auxiliary_dir``.11"""12 13from __future__ import annotations14 15import io16import logging17import operator18import os19import shutil20import traceback21from collections.abc import Iterable, Iterator, Mapping22from contextlib import suppress23from enum import Enum24from inspect import cleandoc25from itertools import chain, starmap26from pathlib import Path27from tempfile import TemporaryDirectory28from types import TracebackType29from typing import TYPE_CHECKING, Protocol, TypeVar, cast30 31from .. import Command, _normalization, _path, _shutil, errors, namespaces32from .._path import StrPath33from ..compat import py310, py31234from ..discovery import find_package_path35from ..dist import Distribution36from ..warnings import InformationOnly, SetuptoolsDeprecationWarning37from .build import build as build_cls38from .build_py import build_py as build_py_cls39from .dist_info import dist_info as dist_info_cls40from .egg_info import egg_info as egg_info_cls41from .install import install as install_cls42from .install_scripts import install_scripts as install_scripts_cls43 44if TYPE_CHECKING:45 from typing_extensions import Self46 47 from .._vendor.wheel.wheelfile import WheelFile48 49_P = TypeVar("_P", bound=StrPath)50_logger = logging.getLogger(__name__)51 52 53class _EditableMode(Enum):54 """55 Possible editable installation modes:56 `lenient` (new files automatically added to the package - DEFAULT);57 `strict` (requires a new installation when files are added/removed); or58 `compat` (attempts to emulate `python setup.py develop` - DEPRECATED).59 """60 61 STRICT = "strict"62 LENIENT = "lenient"63 COMPAT = "compat" # TODO: Remove `compat` after Dec/2022.64 65 @classmethod66 def convert(cls, mode: str | None) -> _EditableMode:67 if not mode:68 return _EditableMode.LENIENT # default69 70 _mode = mode.upper()71 if _mode not in _EditableMode.__members__:72 raise errors.OptionError(f"Invalid editable mode: {mode!r}. Try: 'strict'.")73 74 if _mode == "COMPAT":75 SetuptoolsDeprecationWarning.emit(76 "Compat editable installs",77 """78 The 'compat' editable mode is transitional and will be removed79 in future versions of `setuptools`.80 Please adapt your code accordingly to use either the 'strict' or the81 'lenient' modes.82 """,83 see_docs="userguide/development_mode.html",84 # TODO: define due_date85 # There is a series of shortcomings with the available editable install86 # methods, and they are very controversial. This is something that still87 # needs work.88 # Moreover, `pip` is still hiding this warning, so users are not aware.89 )90 91 return _EditableMode[_mode]92 93 94_STRICT_WARNING = """95New or renamed files may not be automatically picked up without a new installation.96"""97 98_LENIENT_WARNING = """99Options like `package-data`, `include/exclude-package-data` or100`packages.find.exclude/include` may have no effect.101"""102 103 104class editable_wheel(Command):105 """Build 'editable' wheel for development.106 This command is private and reserved for internal use of setuptools,107 users should rely on ``setuptools.build_meta`` APIs.108 """109 110 description = "DO NOT CALL DIRECTLY, INTERNAL ONLY: create PEP 660 editable wheel"111 112 user_options = [113 ("dist-dir=", "d", "directory to put final built distributions in"),114 ("dist-info-dir=", "I", "path to a pre-build .dist-info directory"),115 ("mode=", None, cleandoc(_EditableMode.__doc__ or "")),116 ]117 118 def initialize_options(self):119 self.dist_dir = None120 self.dist_info_dir = None121 self.project_dir = None122 self.mode = None123 124 def finalize_options(self) -> None:125 dist = self.distribution126 self.project_dir = dist.src_root or os.curdir127 self.package_dir = dist.package_dir or {}128 self.dist_dir = Path(self.dist_dir or os.path.join(self.project_dir, "dist"))129 130 def run(self) -> None:131 try:132 self.dist_dir.mkdir(exist_ok=True)133 self._ensure_dist_info()134 135 # Add missing dist_info files136 self.reinitialize_command("bdist_wheel")137 bdist_wheel = self.get_finalized_command("bdist_wheel")138 bdist_wheel.write_wheelfile(self.dist_info_dir)139 140 self._create_wheel_file(bdist_wheel)141 except Exception as ex:142 project = self.distribution.name or self.distribution.get_name()143 py310.add_note(144 ex,145 f"An error occurred when building editable wheel for {project}.\n"146 "See debugging tips in: "147 "https://setuptools.pypa.io/en/latest/userguide/development_mode.html#debugging-tips",148 )149 raise150 151 def _ensure_dist_info(self):152 if self.dist_info_dir is None:153 dist_info = cast(dist_info_cls, self.reinitialize_command("dist_info"))154 dist_info.output_dir = self.dist_dir155 dist_info.ensure_finalized()156 dist_info.run()157 self.dist_info_dir = dist_info.dist_info_dir158 else:159 assert str(self.dist_info_dir).endswith(".dist-info")160 assert Path(self.dist_info_dir, "METADATA").exists()161 162 def _install_namespaces(self, installation_dir, pth_prefix):163 # XXX: Only required to support the deprecated namespace practice164 dist = self.distribution165 if not dist.namespace_packages:166 return167 168 src_root = Path(self.project_dir, self.package_dir.get("", ".")).resolve()169 installer = _NamespaceInstaller(dist, installation_dir, pth_prefix, src_root)170 installer.install_namespaces()171 172 def _find_egg_info_dir(self) -> str | None:173 parent_dir = Path(self.dist_info_dir).parent if self.dist_info_dir else Path()174 candidates = map(str, parent_dir.glob("*.egg-info"))175 return next(candidates, None)176 177 def _configure_build(178 self, name: str, unpacked_wheel: StrPath, build_lib: StrPath, tmp_dir: StrPath179 ):180 """Configure commands to behave in the following ways:181 182 - Build commands can write to ``build_lib`` if they really want to...183 (but this folder is expected to be ignored and modules are expected to live184 in the project directory...)185 - Binary extensions should be built in-place (editable_mode = True)186 - Data/header/script files are not part of the "editable" specification187 so they are written directly to the unpacked_wheel directory.188 """189 # Non-editable files (data, headers, scripts) are written directly to the190 # unpacked_wheel191 192 dist = self.distribution193 wheel = str(unpacked_wheel)194 build_lib = str(build_lib)195 data = str(Path(unpacked_wheel, f"{name}.data", "data"))196 headers = str(Path(unpacked_wheel, f"{name}.data", "headers"))197 scripts = str(Path(unpacked_wheel, f"{name}.data", "scripts"))198 199 # egg-info may be generated again to create a manifest (used for package data)200 egg_info = cast(201 egg_info_cls, dist.reinitialize_command("egg_info", reinit_subcommands=True)202 )203 egg_info.egg_base = str(tmp_dir)204 egg_info.ignore_egg_info_in_manifest = True205 206 build = cast(207 build_cls, dist.reinitialize_command("build", reinit_subcommands=True)208 )209 install = cast(210 install_cls, dist.reinitialize_command("install", reinit_subcommands=True)211 )212 213 build.build_platlib = build.build_purelib = build.build_lib = build_lib214 install.install_purelib = install.install_platlib = install.install_lib = wheel215 install.install_scripts = build.build_scripts = scripts216 install.install_headers = headers217 install.install_data = data218 219 # For portability, ensure scripts are built with #!python shebang220 # pypa/setuptools#4863221 build_scripts = dist.get_command_obj("build_scripts")222 build_scripts.executable = 'python'223 224 install_scripts = cast(225 install_scripts_cls, dist.get_command_obj("install_scripts")226 )227 install_scripts.no_ep = True228 229 build.build_temp = str(tmp_dir)230 231 build_py = cast(build_py_cls, dist.get_command_obj("build_py"))232 build_py.compile = False233 build_py.existing_egg_info_dir = self._find_egg_info_dir()234 235 self._set_editable_mode()236 237 build.ensure_finalized()238 install.ensure_finalized()239 240 def _set_editable_mode(self):241 """Set the ``editable_mode`` flag in the build sub-commands"""242 dist = self.distribution243 build = dist.get_command_obj("build")244 for cmd_name in build.get_sub_commands():245 cmd = dist.get_command_obj(cmd_name)246 if hasattr(cmd, "editable_mode"):247 cmd.editable_mode = True248 elif hasattr(cmd, "inplace"):249 cmd.inplace = True # backward compatibility with distutils250 251 def _collect_build_outputs(self) -> tuple[list[str], dict[str, str]]:252 files: list[str] = []253 mapping: dict[str, str] = {}254 build = self.get_finalized_command("build")255 256 for cmd_name in build.get_sub_commands():257 cmd = self.get_finalized_command(cmd_name)258 if hasattr(cmd, "get_outputs"):259 files.extend(cmd.get_outputs() or [])260 if hasattr(cmd, "get_output_mapping"):261 mapping.update(cmd.get_output_mapping() or {})262 263 return files, mapping264 265 def _run_build_commands(266 self,267 dist_name: str,268 unpacked_wheel: StrPath,269 build_lib: StrPath,270 tmp_dir: StrPath,271 ) -> tuple[list[str], dict[str, str]]:272 self._configure_build(dist_name, unpacked_wheel, build_lib, tmp_dir)273 self._run_build_subcommands()274 files, mapping = self._collect_build_outputs()275 self._run_install("headers")276 self._run_install("scripts")277 self._run_install("data")278 return files, mapping279 280 def _run_build_subcommands(self) -> None:281 """282 Issue #3501 indicates that some plugins/customizations might rely on:283 284 1. ``build_py`` not running285 2. ``build_py`` always copying files to ``build_lib``286 287 However both these assumptions may be false in editable_wheel.288 This method implements a temporary workaround to support the ecosystem289 while the implementations catch up.290 """291 # TODO: Once plugins/customizations had the chance to catch up, replace292 # `self._run_build_subcommands()` with `self.run_command("build")`.293 # Also remove _safely_run, TestCustomBuildPy. Suggested date: Aug/2023.294 build = self.get_finalized_command("build")295 for name in build.get_sub_commands():296 cmd = self.get_finalized_command(name)297 if name == "build_py" and type(cmd) is not build_py_cls:298 self._safely_run(name)299 else:300 self.run_command(name)301 302 def _safely_run(self, cmd_name: str):303 try:304 return self.run_command(cmd_name)305 except Exception:306 SetuptoolsDeprecationWarning.emit(307 "Customization incompatible with editable install",308 f"""309 {traceback.format_exc()}310 311 If you are seeing this warning it is very likely that a setuptools312 plugin or customization overrides the `{cmd_name}` command, without313 taking into consideration how editable installs run build steps314 starting from setuptools v64.0.0.315 316 Plugin authors and developers relying on custom build steps are317 encouraged to update their `{cmd_name}` implementation considering the318 information about editable installs in319 https://setuptools.pypa.io/en/latest/userguide/extension.html.320 321 For the time being `setuptools` will silence this error and ignore322 the faulty command, but this behavior will change in future versions.323 """,324 # TODO: define due_date325 # There is a series of shortcomings with the available editable install326 # methods, and they are very controversial. This is something that still327 # needs work.328 )329 330 def _create_wheel_file(self, bdist_wheel):331 from wheel.wheelfile import WheelFile332 333 dist_info = self.get_finalized_command("dist_info")334 dist_name = dist_info.name335 tag = "-".join(bdist_wheel.get_tag())336 build_tag = "0.editable" # According to PEP 427 needs to start with digit337 archive_name = f"{dist_name}-{build_tag}-{tag}.whl"338 wheel_path = Path(self.dist_dir, archive_name)339 if wheel_path.exists():340 wheel_path.unlink()341 342 unpacked_wheel = TemporaryDirectory(suffix=archive_name)343 build_lib = TemporaryDirectory(suffix=".build-lib")344 build_tmp = TemporaryDirectory(suffix=".build-temp")345 346 with unpacked_wheel as unpacked, build_lib as lib, build_tmp as tmp:347 unpacked_dist_info = Path(unpacked, Path(self.dist_info_dir).name)348 shutil.copytree(self.dist_info_dir, unpacked_dist_info)349 self._install_namespaces(unpacked, dist_name)350 files, mapping = self._run_build_commands(dist_name, unpacked, lib, tmp)351 strategy = self._select_strategy(dist_name, tag, lib)352 with strategy, WheelFile(wheel_path, "w") as wheel_obj:353 strategy(wheel_obj, files, mapping)354 wheel_obj.write_files(unpacked)355 356 return wheel_path357 358 def _run_install(self, category: str):359 has_category = getattr(self.distribution, f"has_{category}", None)360 if has_category and has_category():361 _logger.info(f"Installing {category} as non editable")362 self.run_command(f"install_{category}")363 364 def _select_strategy(365 self,366 name: str,367 tag: str,368 build_lib: StrPath,369 ) -> EditableStrategy:370 """Decides which strategy to use to implement an editable installation."""371 build_name = f"__editable__.{name}-{tag}"372 project_dir = Path(self.project_dir)373 mode = _EditableMode.convert(self.mode)374 375 if mode is _EditableMode.STRICT:376 auxiliary_dir = _empty_dir(Path(self.project_dir, "build", build_name))377 return _LinkTree(self.distribution, name, auxiliary_dir, build_lib)378 379 packages = _find_packages(self.distribution)380 has_simple_layout = _simple_layout(packages, self.package_dir, project_dir)381 is_compat_mode = mode is _EditableMode.COMPAT382 if set(self.package_dir) == {""} and has_simple_layout or is_compat_mode:383 # src-layout(ish) is relatively safe for a simple pth file384 src_dir = self.package_dir.get("", ".")385 return _StaticPth(self.distribution, name, [Path(project_dir, src_dir)])386 387 # Use a MetaPathFinder to avoid adding accidental top-level packages/modules388 return _TopLevelFinder(self.distribution, name)389 390 391class EditableStrategy(Protocol):392 def __call__(393 self, wheel: WheelFile, files: list[str], mapping: Mapping[str, str]394 ) -> object: ...395 def __enter__(self) -> Self: ...396 def __exit__(397 self,398 _exc_type: type[BaseException] | None,399 _exc_value: BaseException | None,400 _traceback: TracebackType | None,401 ) -> object: ...402 403 404class _StaticPth:405 def __init__(self, dist: Distribution, name: str, path_entries: list[Path]) -> None:406 self.dist = dist407 self.name = name408 self.path_entries = path_entries409 410 def __call__(411 self, wheel: WheelFile, files: list[str], mapping: Mapping[str, str]412 ) -> None:413 entries = "\n".join(str(p.resolve()) for p in self.path_entries)414 contents = _encode_pth(f"{entries}\n")415 wheel.writestr(f"__editable__.{self.name}.pth", contents)416 417 def __enter__(self) -> Self:418 msg = f"""419 Editable install will be performed using .pth file to extend `sys.path` with:420 {list(map(os.fspath, self.path_entries))!r}421 """422 _logger.warning(msg + _LENIENT_WARNING)423 return self424 425 def __exit__(426 self,427 _exc_type: object,428 _exc_value: object,429 _traceback: object,430 ) -> None:431 pass432 433 434class _LinkTree(_StaticPth):435 """436 Creates a ``.pth`` file that points to a link tree in the ``auxiliary_dir``.437 438 This strategy will only link files (not dirs), so it can be implemented in439 any OS, even if that means using hardlinks instead of symlinks.440 441 By collocating ``auxiliary_dir`` and the original source code, limitations442 with hardlinks should be avoided.443 """444 445 def __init__(446 self,447 dist: Distribution,448 name: str,449 auxiliary_dir: StrPath,450 build_lib: StrPath,451 ) -> None:452 self.auxiliary_dir = Path(auxiliary_dir)453 self.build_lib = Path(build_lib).resolve()454 self._file = dist.get_command_obj("build_py").copy_file455 super().__init__(dist, name, [self.auxiliary_dir])456 457 def __call__(458 self, wheel: WheelFile, files: list[str], mapping: Mapping[str, str]459 ) -> None:460 self._create_links(files, mapping)461 super().__call__(wheel, files, mapping)462 463 def _normalize_output(self, file: str) -> str | None:464 # Files relative to build_lib will be normalized to None465 with suppress(ValueError):466 path = Path(file).resolve().relative_to(self.build_lib)467 return str(path).replace(os.sep, '/')468 return None469 470 def _create_file(self, relative_output: str, src_file: str, link=None):471 dest = self.auxiliary_dir / relative_output472 if not dest.parent.is_dir():473 dest.parent.mkdir(parents=True)474 self._file(src_file, dest, link=link)475 476 def _create_links(self, outputs, output_mapping: Mapping[str, str]):477 self.auxiliary_dir.mkdir(parents=True, exist_ok=True)478 link_type = "sym" if _can_symlink_files(self.auxiliary_dir) else "hard"479 normalised = ((self._normalize_output(k), v) for k, v in output_mapping.items())480 # remove files that are not relative to build_lib481 mappings = {k: v for k, v in normalised if k is not None}482 483 for output in outputs:484 relative = self._normalize_output(output)485 if relative and relative not in mappings:486 self._create_file(relative, output)487 488 for relative, src in mappings.items():489 self._create_file(relative, src, link=link_type)490 491 def __enter__(self) -> Self:492 msg = "Strict editable install will be performed using a link tree.\n"493 _logger.warning(msg + _STRICT_WARNING)494 return self495 496 def __exit__(497 self,498 _exc_type: object,499 _exc_value: object,500 _traceback: object,501 ) -> None:502 msg = f"""\n503 Strict editable installation performed using the auxiliary directory:504 {self.auxiliary_dir}505 506 Please be careful to not remove this directory, otherwise you might not be able507 to import/use your package.508 """509 InformationOnly.emit("Editable installation.", msg)510 511 512class _TopLevelFinder:513 def __init__(self, dist: Distribution, name: str) -> None:514 self.dist = dist515 self.name = name516 517 def template_vars(self) -> tuple[str, str, dict[str, str], dict[str, list[str]]]:518 src_root = self.dist.src_root or os.curdir519 top_level = chain(_find_packages(self.dist), _find_top_level_modules(self.dist))520 package_dir = self.dist.package_dir or {}521 roots = _find_package_roots(top_level, package_dir, src_root)522 523 namespaces_ = dict(524 chain(525 _find_namespaces(self.dist.packages or [], roots),526 ((ns, []) for ns in _find_virtual_namespaces(roots)),527 )528 )529 530 legacy_namespaces = {531 pkg: find_package_path(pkg, roots, self.dist.src_root or "")532 for pkg in self.dist.namespace_packages or []533 }534 535 mapping = {**roots, **legacy_namespaces}536 # ^-- We need to explicitly add the legacy_namespaces to the mapping to be537 # able to import their modules even if another package sharing the same538 # namespace is installed in a conventional (non-editable) way.539 540 name = f"__editable__.{self.name}.finder"541 finder = _normalization.safe_identifier(name)542 return finder, name, mapping, namespaces_543 544 def get_implementation(self) -> Iterator[tuple[str, bytes]]:545 finder, name, mapping, namespaces_ = self.template_vars()546 547 content = bytes(_finder_template(name, mapping, namespaces_), "utf-8")548 yield (f"{finder}.py", content)549 550 content = _encode_pth(f"import {finder}; {finder}.install()")551 yield (f"__editable__.{self.name}.pth", content)552 553 def __call__(554 self, wheel: WheelFile, files: list[str], mapping: Mapping[str, str]555 ) -> None:556 for file, content in self.get_implementation():557 wheel.writestr(file, content)558 559 def __enter__(self) -> Self:560 msg = "Editable install will be performed using a meta path finder.\n"561 _logger.warning(msg + _LENIENT_WARNING)562 return self563 564 def __exit__(565 self,566 _exc_type: object,567 _exc_value: object,568 _traceback: object,569 ) -> None:570 msg = """\n571 Please be careful with folders in your working directory with the same572 name as your package as they may take precedence during imports.573 """574 InformationOnly.emit("Editable installation.", msg)575 576 577def _encode_pth(content: str) -> bytes:578 """579 Prior to Python 3.13 (see https://github.com/python/cpython/issues/77102),580 .pth files are always read with 'locale' encoding, the recommendation581 from the cpython core developers is to write them as ``open(path, "w")``582 and ignore warnings (see python/cpython#77102, pypa/setuptools#3937).583 This function tries to simulate this behavior without having to create an584 actual file, in a way that supports a range of active Python versions.585 (There seems to be some variety in the way different version of Python handle586 ``encoding=None``, not all of them use ``locale.getpreferredencoding(False)``587 or ``locale.getencoding()``).588 """589 with io.BytesIO() as buffer:590 wrapper = io.TextIOWrapper(buffer, encoding=py312.PTH_ENCODING)591 # TODO: Python 3.13 replace the whole function with `bytes(content, "utf-8")`592 wrapper.write(content)593 wrapper.flush()594 buffer.seek(0)595 return buffer.read()596 597 598def _can_symlink_files(base_dir: Path) -> bool:599 with TemporaryDirectory(dir=str(base_dir.resolve())) as tmp:600 path1, path2 = Path(tmp, "file1.txt"), Path(tmp, "file2.txt")601 path1.write_text("file1", encoding="utf-8")602 with suppress(AttributeError, NotImplementedError, OSError):603 os.symlink(path1, path2)604 if path2.is_symlink() and path2.read_text(encoding="utf-8") == "file1":605 return True606 607 try:608 os.link(path1, path2) # Ensure hard links can be created609 except Exception as ex:610 msg = (611 "File system does not seem to support either symlinks or hard links. "612 "Strict editable installs require one of them to be supported."613 )614 raise LinksNotSupported(msg) from ex615 return False616 617 618def _simple_layout(619 packages: Iterable[str], package_dir: dict[str, str], project_dir: StrPath620) -> bool:621 """Return ``True`` if:622 - all packages are contained by the same parent directory, **and**623 - all packages become importable if the parent directory is added to ``sys.path``.624 625 >>> _simple_layout(['a'], {"": "src"}, "/tmp/myproj")626 True627 >>> _simple_layout(['a', 'a.b'], {"": "src"}, "/tmp/myproj")628 True629 >>> _simple_layout(['a', 'a.b'], {}, "/tmp/myproj")630 True631 >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"": "src"}, "/tmp/myproj")632 True633 >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a": "a", "b": "b"}, ".")634 True635 >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a": "_a", "b": "_b"}, ".")636 False637 >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a": "_a"}, "/tmp/myproj")638 False639 >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a.a1.a2": "_a2"}, ".")640 False641 >>> _simple_layout(['a', 'a.b'], {"": "src", "a.b": "_ab"}, "/tmp/myproj")642 False643 >>> # Special cases, no packages yet:644 >>> _simple_layout([], {"": "src"}, "/tmp/myproj")645 True646 >>> _simple_layout([], {"a": "_a", "": "src"}, "/tmp/myproj")647 False648 """649 layout = {pkg: find_package_path(pkg, package_dir, project_dir) for pkg in packages}650 if not layout:651 return set(package_dir) in ({}, {""})652 parent = os.path.commonpath(starmap(_parent_path, layout.items()))653 return all(654 _path.same_path(Path(parent, *key.split('.')), value)655 for key, value in layout.items()656 )657 658 659def _parent_path(pkg, pkg_path):660 """Infer the parent path containing a package, that if added to ``sys.path`` would661 allow importing that package.662 When ``pkg`` is directly mapped into a directory with a different name, return its663 own path.664 >>> _parent_path("a", "src/a")665 'src'666 >>> _parent_path("b", "src/c")667 'src/c'668 """669 parent = pkg_path.removesuffix(pkg)670 return parent.rstrip("/" + os.sep)671 672 673def _find_packages(dist: Distribution) -> Iterator[str]:674 yield from iter(dist.packages or [])675 676 py_modules = dist.py_modules or []677 nested_modules = [mod for mod in py_modules if "." in mod]678 if dist.ext_package:679 yield dist.ext_package680 else:681 ext_modules = dist.ext_modules or []682 nested_modules += [x.name for x in ext_modules if "." in x.name]683 684 for module in nested_modules:685 package, _, _ = module.rpartition(".")686 yield package687 688 689def _find_top_level_modules(dist: Distribution) -> Iterator[str]:690 py_modules = dist.py_modules or []691 yield from (mod for mod in py_modules if "." not in mod)692 693 if not dist.ext_package:694 ext_modules = dist.ext_modules or []695 yield from (x.name for x in ext_modules if "." not in x.name)696 697 698def _find_package_roots(699 packages: Iterable[str],700 package_dir: Mapping[str, str],701 src_root: StrPath,702) -> dict[str, str]:703 pkg_roots: dict[str, str] = {704 pkg: _absolute_root(find_package_path(pkg, package_dir, src_root))705 for pkg in sorted(packages)706 }707 708 return _remove_nested(pkg_roots)709 710 711def _absolute_root(path: StrPath) -> str:712 """Works for packages and top-level modules"""713 path_ = Path(path)714 parent = path_.parent715 716 if path_.exists():717 return str(path_.resolve())718 else:719 return str(parent.resolve() / path_.name)720 721 722def _find_virtual_namespaces(pkg_roots: dict[str, str]) -> Iterator[str]:723 """By carefully designing ``package_dir``, it is possible to implement the logical724 structure of PEP 420 in a package without the corresponding directories.725 726 Moreover a parent package can be purposefully/accidentally skipped in the discovery727 phase (e.g. ``find_packages(include=["mypkg.*"])``, when ``mypkg.foo`` is included728 by ``mypkg`` itself is not).729 We consider this case to also be a virtual namespace (ignoring the original730 directory) to emulate a non-editable installation.731 732 This function will try to find these kinds of namespaces.733 """734 for pkg in pkg_roots:735 if "." not in pkg:736 continue737 parts = pkg.split(".")738 for i in range(len(parts) - 1, 0, -1):739 partial_name = ".".join(parts[:i])740 path = Path(find_package_path(partial_name, pkg_roots, ""))741 if not path.exists() or partial_name not in pkg_roots:742 # partial_name not in pkg_roots ==> purposefully/accidentally skipped743 yield partial_name744 745 746def _find_namespaces(747 packages: list[str], pkg_roots: dict[str, str]748) -> Iterator[tuple[str, list[str]]]:749 for pkg in packages:750 path = find_package_path(pkg, pkg_roots, "")751 if Path(path).exists() and not Path(path, "__init__.py").exists():752 yield (pkg, [path])753 754 755def _remove_nested(pkg_roots: dict[str, str]) -> dict[str, str]:756 output = dict(pkg_roots.copy())757 758 for pkg, path in reversed(list(pkg_roots.items())):759 if any(760 pkg != other and _is_nested(pkg, path, other, other_path)761 for other, other_path in pkg_roots.items()762 ):763 output.pop(pkg)764 765 return output766 767 768def _is_nested(pkg: str, pkg_path: str, parent: str, parent_path: str) -> bool:769 """770 Return ``True`` if ``pkg`` is nested inside ``parent`` both logically and in the771 file system.772 >>> _is_nested("a.b", "path/a/b", "a", "path/a")773 True774 >>> _is_nested("a.b", "path/a/b", "a", "otherpath/a")775 False776 >>> _is_nested("a.b", "path/a/b", "c", "path/c")777 False778 >>> _is_nested("a.a", "path/a/a", "a", "path/a")779 True780 >>> _is_nested("b.a", "path/b/a", "a", "path/a")781 False782 """783 norm_pkg_path = _path.normpath(pkg_path)784 rest = pkg.replace(parent, "", 1).strip(".").split(".")785 return pkg.startswith(parent) and norm_pkg_path == _path.normpath(786 Path(parent_path, *rest)787 )788 789 790def _empty_dir(dir_: _P) -> _P:791 """Create a directory ensured to be empty. Existing files may be removed."""792 _shutil.rmtree(dir_, ignore_errors=True)793 os.makedirs(dir_)794 return dir_795 796 797class _NamespaceInstaller(namespaces.Installer):798 def __init__(self, distribution, installation_dir, editable_name, src_root) -> None:799 self.distribution = distribution800 self.src_root = src_root801 self.installation_dir = installation_dir802 self.editable_name = editable_name803 self.outputs: list[str] = []804 805 def _get_nspkg_file(self):806 """Installation target."""807 return os.path.join(self.installation_dir, self.editable_name + self.nspkg_ext)808 809 def _get_root(self):810 """Where the modules/packages should be loaded from."""811 return repr(str(self.src_root))812 813 814_FINDER_TEMPLATE = """\815from __future__ import annotations816import sys817from importlib.machinery import ModuleSpec, PathFinder818from importlib.machinery import all_suffixes as module_suffixes819from importlib.util import spec_from_file_location820from itertools import chain821from pathlib import Path822 823MAPPING: dict[str, str] = {mapping!r}824NAMESPACES: dict[str, list[str]] = {namespaces!r}825PATH_PLACEHOLDER = {name!r} + ".__path_hook__"826 827 828class _EditableFinder: # MetaPathFinder829 @classmethod830 def find_spec(cls, fullname: str, path=None, target=None) -> ModuleSpec | None: # type: ignore831 # Top-level packages and modules (we know these exist in the FS)832 if fullname in MAPPING:833 pkg_path = MAPPING[fullname]834 return cls._find_spec(fullname, Path(pkg_path))835 836 # Handle immediate children modules (required for namespaces to work)837 # To avoid problems with case sensitivity in the file system we delegate838 # to the importlib.machinery implementation.839 parent, _, child = fullname.rpartition(".")840 if parent and parent in MAPPING:841 return PathFinder.find_spec(fullname, path=[MAPPING[parent]])842 843 # Other levels of nesting should be handled automatically by importlib844 # using the parent path.845 return None846 847 @classmethod848 def _find_spec(cls, fullname: str, candidate_path: Path) -> ModuleSpec | None:849 init = candidate_path / "__init__.py"850 candidates = (candidate_path.with_suffix(x) for x in module_suffixes())851 for candidate in chain([init], candidates):852 if candidate.exists():853 return spec_from_file_location(fullname, candidate)854 return None855 856 857class _EditableNamespaceFinder: # PathEntryFinder858 @classmethod859 def _path_hook(cls, path) -> type[_EditableNamespaceFinder]:860 if path == PATH_PLACEHOLDER:861 return cls862 raise ImportError863 864 @classmethod865 def _paths(cls, fullname: str) -> list[str]:866 paths = NAMESPACES[fullname]867 if not paths and fullname in MAPPING:868 paths = [MAPPING[fullname]]869 # Always add placeholder, for 2 reasons:870 # 1. __path__ cannot be empty for the spec to be considered namespace.871 # 2. In the case of nested namespaces, we need to force872 # import machinery to query _EditableNamespaceFinder again.873 return [*paths, PATH_PLACEHOLDER]874 875 @classmethod876 def find_spec(cls, fullname: str, target=None) -> ModuleSpec | None: # type: ignore877 if fullname in NAMESPACES:878 spec = ModuleSpec(fullname, None, is_package=True)879 spec.submodule_search_locations = cls._paths(fullname)880 return spec881 return None882 883 @classmethod884 def find_module(cls, _fullname) -> None:885 return None886 887 888def install():889 if not any(finder == _EditableFinder for finder in sys.meta_path):890 sys.meta_path.append(_EditableFinder)891 892 if not NAMESPACES:893 return894 895 if not any(hook == _EditableNamespaceFinder._path_hook for hook in sys.path_hooks):896 # PathEntryFinder is needed to create NamespaceSpec without private APIS897 sys.path_hooks.append(_EditableNamespaceFinder._path_hook)898 if PATH_PLACEHOLDER not in sys.path:899 sys.path.append(PATH_PLACEHOLDER) # Used just to trigger the path hook900"""901 902 903def _finder_template(904 name: str, mapping: Mapping[str, str], namespaces: dict[str, list[str]]905) -> str:906 """Create a string containing the code for the``MetaPathFinder`` and907 ``PathEntryFinder``.908 """909 mapping = dict(sorted(mapping.items(), key=operator.itemgetter(0)))910 return _FINDER_TEMPLATE.format(name=name, mapping=mapping, namespaces=namespaces)911 912 913class LinksNotSupported(errors.FileError):914 """File system does not seem to support either symlinks or hard links."""915 