codekingpro/portable-devtools
114k
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 13import logging14import io15import os16import shutil17import traceback18from contextlib import suppress19from enum import Enum20from inspect import cleandoc21from itertools import chain, starmap22from pathlib import Path23from tempfile import TemporaryDirectory24from typing import (25 TYPE_CHECKING,26 Dict,27 Iterable,28 Iterator,29 List,30 Mapping,31 Optional,32 Protocol,33 Tuple,34 TypeVar,35 cast,36)37 38from .. import (39 Command,40 _normalization,41 _path,42 errors,43 namespaces,44)45from .._path import StrPath46from ..compat import py3947from ..discovery import find_package_path48from ..dist import Distribution49from ..warnings import (50 InformationOnly,51 SetuptoolsDeprecationWarning,52 SetuptoolsWarning,53)54from .build import build as build_cls55from .build_py import build_py as build_py_cls56from .dist_info import dist_info as dist_info_cls57from .egg_info import egg_info as egg_info_cls58from .install import install as install_cls59from .install_scripts import install_scripts as install_scripts_cls60 61if TYPE_CHECKING:62 from wheel.wheelfile import WheelFile # type:ignore[import-untyped] # noqa63 64_P = TypeVar("_P", bound=StrPath)65_logger = logging.getLogger(__name__)66 67 68class _EditableMode(Enum):69 """70 Possible editable installation modes:71 `lenient` (new files automatically added to the package - DEFAULT);72 `strict` (requires a new installation when files are added/removed); or73 `compat` (attempts to emulate `python setup.py develop` - DEPRECATED).74 """75 76 STRICT = "strict"77 LENIENT = "lenient"78 COMPAT = "compat" # TODO: Remove `compat` after Dec/2022.79 80 @classmethod81 def convert(cls, mode: Optional[str]) -> "_EditableMode":82 if not mode:83 return _EditableMode.LENIENT # default84 85 _mode = mode.upper()86 if _mode not in _EditableMode.__members__:87 raise errors.OptionError(f"Invalid editable mode: {mode!r}. Try: 'strict'.")88 89 if _mode == "COMPAT":90 SetuptoolsDeprecationWarning.emit(91 "Compat editable installs",92 """93 The 'compat' editable mode is transitional and will be removed94 in future versions of `setuptools`.95 Please adapt your code accordingly to use either the 'strict' or the96 'lenient' modes.97 """,98 see_docs="userguide/development_mode.html",99 # TODO: define due_date100 # There is a series of shortcomings with the available editable install101 # methods, and they are very controversial. This is something that still102 # needs work.103 # Moreover, `pip` is still hiding this warning, so users are not aware.104 )105 106 return _EditableMode[_mode]107 108 109_STRICT_WARNING = """110New or renamed files may not be automatically picked up without a new installation.111"""112 113_LENIENT_WARNING = """114Options like `package-data`, `include/exclude-package-data` or115`packages.find.exclude/include` may have no effect.116"""117 118 119class editable_wheel(Command):120 """Build 'editable' wheel for development.121 This command is private and reserved for internal use of setuptools,122 users should rely on ``setuptools.build_meta`` APIs.123 """124 125 description = "DO NOT CALL DIRECTLY, INTERNAL ONLY: create PEP 660 editable wheel"126 127 user_options = [128 ("dist-dir=", "d", "directory to put final built distributions in"),129 ("dist-info-dir=", "I", "path to a pre-build .dist-info directory"),130 ("mode=", None, cleandoc(_EditableMode.__doc__ or "")),131 ]132 133 def initialize_options(self):134 self.dist_dir = None135 self.dist_info_dir = None136 self.project_dir = None137 self.mode = None138 139 def finalize_options(self):140 dist = self.distribution141 self.project_dir = dist.src_root or os.curdir142 self.package_dir = dist.package_dir or {}143 self.dist_dir = Path(self.dist_dir or os.path.join(self.project_dir, "dist"))144 145 def run(self):146 try:147 self.dist_dir.mkdir(exist_ok=True)148 self._ensure_dist_info()149 150 # Add missing dist_info files151 self.reinitialize_command("bdist_wheel")152 bdist_wheel = self.get_finalized_command("bdist_wheel")153 bdist_wheel.write_wheelfile(self.dist_info_dir)154 155 self._create_wheel_file(bdist_wheel)156 except Exception:157 traceback.print_exc()158 project = self.distribution.name or self.distribution.get_name()159 _DebuggingTips.emit(project=project)160 raise161 162 def _ensure_dist_info(self):163 if self.dist_info_dir is None:164 dist_info = cast(dist_info_cls, self.reinitialize_command("dist_info"))165 dist_info.output_dir = self.dist_dir166 dist_info.ensure_finalized()167 dist_info.run()168 self.dist_info_dir = dist_info.dist_info_dir169 else:170 assert str(self.dist_info_dir).endswith(".dist-info")171 assert Path(self.dist_info_dir, "METADATA").exists()172 173 def _install_namespaces(self, installation_dir, pth_prefix):174 # XXX: Only required to support the deprecated namespace practice175 dist = self.distribution176 if not dist.namespace_packages:177 return178 179 src_root = Path(self.project_dir, self.package_dir.get("", ".")).resolve()180 installer = _NamespaceInstaller(dist, installation_dir, pth_prefix, src_root)181 installer.install_namespaces()182 183 def _find_egg_info_dir(self) -> Optional[str]:184 parent_dir = Path(self.dist_info_dir).parent if self.dist_info_dir else Path()185 candidates = map(str, parent_dir.glob("*.egg-info"))186 return next(candidates, None)187 188 def _configure_build(189 self, name: str, unpacked_wheel: StrPath, build_lib: StrPath, tmp_dir: StrPath190 ):191 """Configure commands to behave in the following ways:192 193 - Build commands can write to ``build_lib`` if they really want to...194 (but this folder is expected to be ignored and modules are expected to live195 in the project directory...)196 - Binary extensions should be built in-place (editable_mode = True)197 - Data/header/script files are not part of the "editable" specification198 so they are written directly to the unpacked_wheel directory.199 """200 # Non-editable files (data, headers, scripts) are written directly to the201 # unpacked_wheel202 203 dist = self.distribution204 wheel = str(unpacked_wheel)205 build_lib = str(build_lib)206 data = str(Path(unpacked_wheel, f"{name}.data", "data"))207 headers = str(Path(unpacked_wheel, f"{name}.data", "headers"))208 scripts = str(Path(unpacked_wheel, f"{name}.data", "scripts"))209 210 # egg-info may be generated again to create a manifest (used for package data)211 egg_info = cast(212 egg_info_cls, dist.reinitialize_command("egg_info", reinit_subcommands=True)213 )214 egg_info.egg_base = str(tmp_dir)215 egg_info.ignore_egg_info_in_manifest = True216 217 build = cast(218 build_cls, dist.reinitialize_command("build", reinit_subcommands=True)219 )220 install = cast(221 install_cls, dist.reinitialize_command("install", reinit_subcommands=True)222 )223 224 build.build_platlib = build.build_purelib = build.build_lib = build_lib225 install.install_purelib = install.install_platlib = install.install_lib = wheel226 install.install_scripts = build.build_scripts = scripts227 install.install_headers = headers228 install.install_data = data229 230 install_scripts = cast(231 install_scripts_cls, dist.get_command_obj("install_scripts")232 )233 install_scripts.no_ep = True234 235 build.build_temp = str(tmp_dir)236 237 build_py = cast(build_py_cls, dist.get_command_obj("build_py"))238 build_py.compile = False239 build_py.existing_egg_info_dir = self._find_egg_info_dir()240 241 self._set_editable_mode()242 243 build.ensure_finalized()244 install.ensure_finalized()245 246 def _set_editable_mode(self):247 """Set the ``editable_mode`` flag in the build sub-commands"""248 dist = self.distribution249 build = dist.get_command_obj("build")250 # TODO: Update typeshed distutils stubs to overload non-None return type by default251 for cmd_name in build.get_sub_commands():252 cmd = dist.get_command_obj(cmd_name)253 if hasattr(cmd, "editable_mode"):254 cmd.editable_mode = True255 elif hasattr(cmd, "inplace"):256 cmd.inplace = True # backward compatibility with distutils257 258 def _collect_build_outputs(self) -> Tuple[List[str], Dict[str, str]]:259 files: List[str] = []260 mapping: Dict[str, str] = {}261 build = self.get_finalized_command("build")262 263 for cmd_name in build.get_sub_commands():264 cmd = self.get_finalized_command(cmd_name)265 if hasattr(cmd, "get_outputs"):266 files.extend(cmd.get_outputs() or [])267 if hasattr(cmd, "get_output_mapping"):268 mapping.update(cmd.get_output_mapping() or {})269 270 return files, mapping271 272 def _run_build_commands(273 self,274 dist_name: str,275 unpacked_wheel: StrPath,276 build_lib: StrPath,277 tmp_dir: StrPath,278 ) -> Tuple[List[str], Dict[str, str]]:279 self._configure_build(dist_name, unpacked_wheel, build_lib, tmp_dir)280 self._run_build_subcommands()281 files, mapping = self._collect_build_outputs()282 self._run_install("headers")283 self._run_install("scripts")284 self._run_install("data")285 return files, mapping286 287 def _run_build_subcommands(self) -> None:288 """289 Issue #3501 indicates that some plugins/customizations might rely on:290 291 1. ``build_py`` not running292 2. ``build_py`` always copying files to ``build_lib``293 294 However both these assumptions may be false in editable_wheel.295 This method implements a temporary workaround to support the ecosystem296 while the implementations catch up.297 """298 # TODO: Once plugins/customisations had the chance to catch up, replace299 # `self._run_build_subcommands()` with `self.run_command("build")`.300 # Also remove _safely_run, TestCustomBuildPy. Suggested date: Aug/2023.301 build = self.get_finalized_command("build")302 for name in build.get_sub_commands():303 cmd = self.get_finalized_command(name)304 if name == "build_py" and type(cmd) != build_py_cls:305 self._safely_run(name)306 else:307 self.run_command(name)308 309 def _safely_run(self, cmd_name: str):310 try:311 return self.run_command(cmd_name)312 except Exception:313 SetuptoolsDeprecationWarning.emit(314 "Customization incompatible with editable install",315 f"""316 {traceback.format_exc()}317 318 If you are seeing this warning it is very likely that a setuptools319 plugin or customization overrides the `{cmd_name}` command, without320 taking into consideration how editable installs run build steps321 starting from setuptools v64.0.0.322 323 Plugin authors and developers relying on custom build steps are324 encouraged to update their `{cmd_name}` implementation considering the325 information about editable installs in326 https://setuptools.pypa.io/en/latest/userguide/extension.html.327 328 For the time being `setuptools` will silence this error and ignore329 the faulty command, but this behaviour will change in future versions.330 """,331 # TODO: define due_date332 # There is a series of shortcomings with the available editable install333 # methods, and they are very controversial. This is something that still334 # needs work.335 )336 337 def _create_wheel_file(self, bdist_wheel):338 from wheel.wheelfile import WheelFile339 340 dist_info = self.get_finalized_command("dist_info")341 dist_name = dist_info.name342 tag = "-".join(bdist_wheel.get_tag())343 build_tag = "0.editable" # According to PEP 427 needs to start with digit344 archive_name = f"{dist_name}-{build_tag}-{tag}.whl"345 wheel_path = Path(self.dist_dir, archive_name)346 if wheel_path.exists():347 wheel_path.unlink()348 349 unpacked_wheel = TemporaryDirectory(suffix=archive_name)350 build_lib = TemporaryDirectory(suffix=".build-lib")351 build_tmp = TemporaryDirectory(suffix=".build-temp")352 353 with unpacked_wheel as unpacked, build_lib as lib, build_tmp as tmp:354 unpacked_dist_info = Path(unpacked, Path(self.dist_info_dir).name)355 shutil.copytree(self.dist_info_dir, unpacked_dist_info)356 self._install_namespaces(unpacked, dist_name)357 files, mapping = self._run_build_commands(dist_name, unpacked, lib, tmp)358 strategy = self._select_strategy(dist_name, tag, lib)359 with strategy, WheelFile(wheel_path, "w") as wheel_obj:360 strategy(wheel_obj, files, mapping)361 wheel_obj.write_files(unpacked)362 363 return wheel_path364 365 def _run_install(self, category: str):366 has_category = getattr(self.distribution, f"has_{category}", None)367 if has_category and has_category():368 _logger.info(f"Installing {category} as non editable")369 self.run_command(f"install_{category}")370 371 def _select_strategy(372 self,373 name: str,374 tag: str,375 build_lib: StrPath,376 ) -> "EditableStrategy":377 """Decides which strategy to use to implement an editable installation."""378 build_name = f"__editable__.{name}-{tag}"379 project_dir = Path(self.project_dir)380 mode = _EditableMode.convert(self.mode)381 382 if mode is _EditableMode.STRICT:383 auxiliary_dir = _empty_dir(Path(self.project_dir, "build", build_name))384 return _LinkTree(self.distribution, name, auxiliary_dir, build_lib)385 386 packages = _find_packages(self.distribution)387 has_simple_layout = _simple_layout(packages, self.package_dir, project_dir)388 is_compat_mode = mode is _EditableMode.COMPAT389 if set(self.package_dir) == {""} and has_simple_layout or is_compat_mode:390 # src-layout(ish) is relatively safe for a simple pth file391 src_dir = self.package_dir.get("", ".")392 return _StaticPth(self.distribution, name, [Path(project_dir, src_dir)])393 394 # Use a MetaPathFinder to avoid adding accidental top-level packages/modules395 return _TopLevelFinder(self.distribution, name)396 397 398class EditableStrategy(Protocol):399 def __call__(400 self, wheel: "WheelFile", files: List[str], mapping: Dict[str, str]401 ): ...402 403 def __enter__(self): ...404 405 def __exit__(self, _exc_type, _exc_value, _traceback): ...406 407 408class _StaticPth:409 def __init__(self, dist: Distribution, name: str, path_entries: List[Path]):410 self.dist = dist411 self.name = name412 self.path_entries = path_entries413 414 def __call__(self, wheel: "WheelFile", files: List[str], mapping: Dict[str, str]):415 entries = "\n".join(str(p.resolve()) for p in self.path_entries)416 contents = _encode_pth(f"{entries}\n")417 wheel.writestr(f"__editable__.{self.name}.pth", contents)418 419 def __enter__(self):420 msg = f"""421 Editable install will be performed using .pth file to extend `sys.path` with:422 {list(map(os.fspath, self.path_entries))!r}423 """424 _logger.warning(msg + _LENIENT_WARNING)425 return self426 427 def __exit__(self, _exc_type, _exc_value, _traceback): ...428 429 430class _LinkTree(_StaticPth):431 """432 Creates a ``.pth`` file that points to a link tree in the ``auxiliary_dir``.433 434 This strategy will only link files (not dirs), so it can be implemented in435 any OS, even if that means using hardlinks instead of symlinks.436 437 By collocating ``auxiliary_dir`` and the original source code, limitations438 with hardlinks should be avoided.439 """440 441 def __init__(442 self,443 dist: Distribution,444 name: str,445 auxiliary_dir: StrPath,446 build_lib: StrPath,447 ):448 self.auxiliary_dir = Path(auxiliary_dir)449 self.build_lib = Path(build_lib).resolve()450 # TODO: Update typeshed distutils stubs to overload non-None return type by default451 self._file = dist.get_command_obj("build_py").copy_file # type: ignore[union-attr]452 super().__init__(dist, name, [self.auxiliary_dir])453 454 def __call__(self, wheel: "WheelFile", files: List[str], mapping: Dict[str, str]):455 self._create_links(files, mapping)456 super().__call__(wheel, files, mapping)457 458 def _normalize_output(self, file: str) -> Optional[str]:459 # Files relative to build_lib will be normalized to None460 with suppress(ValueError):461 path = Path(file).resolve().relative_to(self.build_lib)462 return str(path).replace(os.sep, '/')463 return None464 465 def _create_file(self, relative_output: str, src_file: str, link=None):466 dest = self.auxiliary_dir / relative_output467 if not dest.parent.is_dir():468 dest.parent.mkdir(parents=True)469 # TODO: Update typeshed distutils stubs so distutils.cmd.Command.copy_file, accepts PathLike470 # same with methods used by copy_file471 self._file(src_file, dest, link=link) # type: ignore[arg-type]472 473 def _create_links(self, outputs, output_mapping):474 self.auxiliary_dir.mkdir(parents=True, exist_ok=True)475 link_type = "sym" if _can_symlink_files(self.auxiliary_dir) else "hard"476 mappings = {self._normalize_output(k): v for k, v in output_mapping.items()}477 mappings.pop(None, None) # remove files that are not relative to build_lib478 479 for output in outputs:480 relative = self._normalize_output(output)481 if relative and relative not in mappings:482 self._create_file(relative, output)483 484 for relative, src in mappings.items():485 self._create_file(relative, src, link=link_type)486 487 def __enter__(self):488 msg = "Strict editable install will be performed using a link tree.\n"489 _logger.warning(msg + _STRICT_WARNING)490 return self491 492 def __exit__(self, _exc_type, _exc_value, _traceback):493 msg = f"""\n494 Strict editable installation performed using the auxiliary directory:495 {self.auxiliary_dir}496 497 Please be careful to not remove this directory, otherwise you might not be able498 to import/use your package.499 """500 InformationOnly.emit("Editable installation.", msg)501 502 503class _TopLevelFinder:504 def __init__(self, dist: Distribution, name: str):505 self.dist = dist506 self.name = name507 508 def __call__(self, wheel: "WheelFile", files: List[str], mapping: Dict[str, str]):509 src_root = self.dist.src_root or os.curdir510 top_level = chain(_find_packages(self.dist), _find_top_level_modules(self.dist))511 package_dir = self.dist.package_dir or {}512 roots = _find_package_roots(top_level, package_dir, src_root)513 514 namespaces_: Dict[str, List[str]] = dict(515 chain(516 _find_namespaces(self.dist.packages or [], roots),517 ((ns, []) for ns in _find_virtual_namespaces(roots)),518 )519 )520 521 legacy_namespaces = {522 pkg: find_package_path(pkg, roots, self.dist.src_root or "")523 for pkg in self.dist.namespace_packages or []524 }525 526 mapping = {**roots, **legacy_namespaces}527 # ^-- We need to explicitly add the legacy_namespaces to the mapping to be528 # able to import their modules even if another package sharing the same529 # namespace is installed in a conventional (non-editable) way.530 531 name = f"__editable__.{self.name}.finder"532 finder = _normalization.safe_identifier(name)533 content = bytes(_finder_template(name, mapping, namespaces_), "utf-8")534 wheel.writestr(f"{finder}.py", content)535 536 content = _encode_pth(f"import {finder}; {finder}.install()")537 wheel.writestr(f"__editable__.{self.name}.pth", content)538 539 def __enter__(self):540 msg = "Editable install will be performed using a meta path finder.\n"541 _logger.warning(msg + _LENIENT_WARNING)542 return self543 544 def __exit__(self, _exc_type, _exc_value, _traceback):545 msg = """\n546 Please be careful with folders in your working directory with the same547 name as your package as they may take precedence during imports.548 """549 InformationOnly.emit("Editable installation.", msg)550 551 552def _encode_pth(content: str) -> bytes:553 """.pth files are always read with 'locale' encoding, the recommendation554 from the cpython core developers is to write them as ``open(path, "w")``555 and ignore warnings (see python/cpython#77102, pypa/setuptools#3937).556 This function tries to simulate this behaviour without having to create an557 actual file, in a way that supports a range of active Python versions.558 (There seems to be some variety in the way different version of Python handle559 ``encoding=None``, not all of them use ``locale.getpreferredencoding(False)``).560 """561 with io.BytesIO() as buffer:562 wrapper = io.TextIOWrapper(buffer, encoding=py39.LOCALE_ENCODING)563 wrapper.write(content)564 wrapper.flush()565 buffer.seek(0)566 return buffer.read()567 568 569def _can_symlink_files(base_dir: Path) -> bool:570 with TemporaryDirectory(dir=str(base_dir.resolve())) as tmp:571 path1, path2 = Path(tmp, "file1.txt"), Path(tmp, "file2.txt")572 path1.write_text("file1", encoding="utf-8")573 with suppress(AttributeError, NotImplementedError, OSError):574 os.symlink(path1, path2)575 if path2.is_symlink() and path2.read_text(encoding="utf-8") == "file1":576 return True577 578 try:579 os.link(path1, path2) # Ensure hard links can be created580 except Exception as ex:581 msg = (582 "File system does not seem to support either symlinks or hard links. "583 "Strict editable installs require one of them to be supported."584 )585 raise LinksNotSupported(msg) from ex586 return False587 588 589def _simple_layout(590 packages: Iterable[str], package_dir: Dict[str, str], project_dir: StrPath591) -> bool:592 """Return ``True`` if:593 - all packages are contained by the same parent directory, **and**594 - all packages become importable if the parent directory is added to ``sys.path``.595 596 >>> _simple_layout(['a'], {"": "src"}, "/tmp/myproj")597 True598 >>> _simple_layout(['a', 'a.b'], {"": "src"}, "/tmp/myproj")599 True600 >>> _simple_layout(['a', 'a.b'], {}, "/tmp/myproj")601 True602 >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"": "src"}, "/tmp/myproj")603 True604 >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a": "a", "b": "b"}, ".")605 True606 >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a": "_a", "b": "_b"}, ".")607 False608 >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a": "_a"}, "/tmp/myproj")609 False610 >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a.a1.a2": "_a2"}, ".")611 False612 >>> _simple_layout(['a', 'a.b'], {"": "src", "a.b": "_ab"}, "/tmp/myproj")613 False614 >>> # Special cases, no packages yet:615 >>> _simple_layout([], {"": "src"}, "/tmp/myproj")616 True617 >>> _simple_layout([], {"a": "_a", "": "src"}, "/tmp/myproj")618 False619 """620 layout = {pkg: find_package_path(pkg, package_dir, project_dir) for pkg in packages}621 if not layout:622 return set(package_dir) in ({}, {""})623 parent = os.path.commonpath(starmap(_parent_path, layout.items()))624 return all(625 _path.same_path(Path(parent, *key.split('.')), value)626 for key, value in layout.items()627 )628 629 630def _parent_path(pkg, pkg_path):631 """Infer the parent path containing a package, that if added to ``sys.path`` would632 allow importing that package.633 When ``pkg`` is directly mapped into a directory with a different name, return its634 own path.635 >>> _parent_path("a", "src/a")636 'src'637 >>> _parent_path("b", "src/c")638 'src/c'639 """640 parent = pkg_path[: -len(pkg)] if pkg_path.endswith(pkg) else pkg_path641 return parent.rstrip("/" + os.sep)642 643 644def _find_packages(dist: Distribution) -> Iterator[str]:645 yield from iter(dist.packages or [])646 647 py_modules = dist.py_modules or []648 nested_modules = [mod for mod in py_modules if "." in mod]649 if dist.ext_package:650 yield dist.ext_package651 else:652 ext_modules = dist.ext_modules or []653 nested_modules += [x.name for x in ext_modules if "." in x.name]654 655 for module in nested_modules:656 package, _, _ = module.rpartition(".")657 yield package658 659 660def _find_top_level_modules(dist: Distribution) -> Iterator[str]:661 py_modules = dist.py_modules or []662 yield from (mod for mod in py_modules if "." not in mod)663 664 if not dist.ext_package:665 ext_modules = dist.ext_modules or []666 yield from (x.name for x in ext_modules if "." not in x.name)667 668 669def _find_package_roots(670 packages: Iterable[str],671 package_dir: Mapping[str, str],672 src_root: StrPath,673) -> Dict[str, str]:674 pkg_roots: Dict[str, str] = {675 pkg: _absolute_root(find_package_path(pkg, package_dir, src_root))676 for pkg in sorted(packages)677 }678 679 return _remove_nested(pkg_roots)680 681 682def _absolute_root(path: StrPath) -> str:683 """Works for packages and top-level modules"""684 path_ = Path(path)685 parent = path_.parent686 687 if path_.exists():688 return str(path_.resolve())689 else:690 return str(parent.resolve() / path_.name)691 692 693def _find_virtual_namespaces(pkg_roots: Dict[str, str]) -> Iterator[str]:694 """By carefully designing ``package_dir``, it is possible to implement the logical695 structure of PEP 420 in a package without the corresponding directories.696 697 Moreover a parent package can be purposefully/accidentally skipped in the discovery698 phase (e.g. ``find_packages(include=["mypkg.*"])``, when ``mypkg.foo`` is included699 by ``mypkg`` itself is not).700 We consider this case to also be a virtual namespace (ignoring the original701 directory) to emulate a non-editable installation.702 703 This function will try to find these kinds of namespaces.704 """705 for pkg in pkg_roots:706 if "." not in pkg:707 continue708 parts = pkg.split(".")709 for i in range(len(parts) - 1, 0, -1):710 partial_name = ".".join(parts[:i])711 path = Path(find_package_path(partial_name, pkg_roots, ""))712 if not path.exists() or partial_name not in pkg_roots:713 # partial_name not in pkg_roots ==> purposefully/accidentally skipped714 yield partial_name715 716 717def _find_namespaces(718 packages: List[str], pkg_roots: Dict[str, str]719) -> Iterator[Tuple[str, List[str]]]:720 for pkg in packages:721 path = find_package_path(pkg, pkg_roots, "")722 if Path(path).exists() and not Path(path, "__init__.py").exists():723 yield (pkg, [path])724 725 726def _remove_nested(pkg_roots: Dict[str, str]) -> Dict[str, str]:727 output = dict(pkg_roots.copy())728 729 for pkg, path in reversed(list(pkg_roots.items())):730 if any(731 pkg != other and _is_nested(pkg, path, other, other_path)732 for other, other_path in pkg_roots.items()733 ):734 output.pop(pkg)735 736 return output737 738 739def _is_nested(pkg: str, pkg_path: str, parent: str, parent_path: str) -> bool:740 """741 Return ``True`` if ``pkg`` is nested inside ``parent`` both logically and in the742 file system.743 >>> _is_nested("a.b", "path/a/b", "a", "path/a")744 True745 >>> _is_nested("a.b", "path/a/b", "a", "otherpath/a")746 False747 >>> _is_nested("a.b", "path/a/b", "c", "path/c")748 False749 >>> _is_nested("a.a", "path/a/a", "a", "path/a")750 True751 >>> _is_nested("b.a", "path/b/a", "a", "path/a")752 False753 """754 norm_pkg_path = _path.normpath(pkg_path)755 rest = pkg.replace(parent, "", 1).strip(".").split(".")756 return pkg.startswith(parent) and norm_pkg_path == _path.normpath(757 Path(parent_path, *rest)758 )759 760 761def _empty_dir(dir_: _P) -> _P:762 """Create a directory ensured to be empty. Existing files may be removed."""763 shutil.rmtree(dir_, ignore_errors=True)764 os.makedirs(dir_)765 return dir_766 767 768class _NamespaceInstaller(namespaces.Installer):769 def __init__(self, distribution, installation_dir, editable_name, src_root):770 self.distribution = distribution771 self.src_root = src_root772 self.installation_dir = installation_dir773 self.editable_name = editable_name774 self.outputs = []775 self.dry_run = False776 777 def _get_nspkg_file(self):778 """Installation target."""779 return os.path.join(self.installation_dir, self.editable_name + self.nspkg_ext)780 781 def _get_root(self):782 """Where the modules/packages should be loaded from."""783 return repr(str(self.src_root))784 785 786_FINDER_TEMPLATE = """\787import sys788from importlib.machinery import ModuleSpec, PathFinder789from importlib.machinery import all_suffixes as module_suffixes790from importlib.util import spec_from_file_location791from itertools import chain792from pathlib import Path793 794MAPPING = {mapping!r}795NAMESPACES = {namespaces!r}796PATH_PLACEHOLDER = {name!r} + ".__path_hook__"797 798 799class _EditableFinder: # MetaPathFinder800 @classmethod801 def find_spec(cls, fullname, path=None, target=None):802 extra_path = []803 804 # Top-level packages and modules (we know these exist in the FS)805 if fullname in MAPPING:806 pkg_path = MAPPING[fullname]807 return cls._find_spec(fullname, Path(pkg_path))808 809 # Handle immediate children modules (required for namespaces to work)810 # To avoid problems with case sensitivity in the file system we delegate811 # to the importlib.machinery implementation.812 parent, _, child = fullname.rpartition(".")813 if parent and parent in MAPPING:814 return PathFinder.find_spec(fullname, path=[MAPPING[parent], *extra_path])815 816 # Other levels of nesting should be handled automatically by importlib817 # using the parent path.818 return None819 820 @classmethod821 def _find_spec(cls, fullname, candidate_path):822 init = candidate_path / "__init__.py"823 candidates = (candidate_path.with_suffix(x) for x in module_suffixes())824 for candidate in chain([init], candidates):825 if candidate.exists():826 return spec_from_file_location(fullname, candidate)827 828 829class _EditableNamespaceFinder: # PathEntryFinder830 @classmethod831 def _path_hook(cls, path):832 if path == PATH_PLACEHOLDER:833 return cls834 raise ImportError835 836 @classmethod837 def _paths(cls, fullname):838 # Ensure __path__ is not empty for the spec to be considered a namespace.839 return NAMESPACES[fullname] or MAPPING.get(fullname) or [PATH_PLACEHOLDER]840 841 @classmethod842 def find_spec(cls, fullname, target=None):843 if fullname in NAMESPACES:844 spec = ModuleSpec(fullname, None, is_package=True)845 spec.submodule_search_locations = cls._paths(fullname)846 return spec847 return None848 849 @classmethod850 def find_module(cls, fullname):851 return None852 853 854def install():855 if not any(finder == _EditableFinder for finder in sys.meta_path):856 sys.meta_path.append(_EditableFinder)857 858 if not NAMESPACES:859 return860 861 if not any(hook == _EditableNamespaceFinder._path_hook for hook in sys.path_hooks):862 # PathEntryFinder is needed to create NamespaceSpec without private APIS863 sys.path_hooks.append(_EditableNamespaceFinder._path_hook)864 if PATH_PLACEHOLDER not in sys.path:865 sys.path.append(PATH_PLACEHOLDER) # Used just to trigger the path hook866"""867 868 869def _finder_template(870 name: str, mapping: Mapping[str, str], namespaces: Dict[str, List[str]]871) -> str:872 """Create a string containing the code for the``MetaPathFinder`` and873 ``PathEntryFinder``.874 """875 mapping = dict(sorted(mapping.items(), key=lambda p: p[0]))876 return _FINDER_TEMPLATE.format(name=name, mapping=mapping, namespaces=namespaces)877 878 879class LinksNotSupported(errors.FileError):880 """File system does not seem to support either symlinks or hard links."""881 882 883class _DebuggingTips(SetuptoolsWarning):884 _SUMMARY = "Problem in editable installation."885 _DETAILS = """886 An error happened while installing `{project}` in editable mode.887 888 The following steps are recommended to help debug this problem:889 890 - Try to install the project normally, without using the editable mode.891 Does the error still persist?892 (If it does, try fixing the problem before attempting the editable mode).893 - If you are using binary extensions, make sure you have all OS-level894 dependencies installed (e.g. compilers, toolchains, binary libraries, ...).895 - Try the latest version of setuptools (maybe the error was already fixed).896 - If you (or your project dependencies) are using any setuptools extension897 or customization, make sure they support the editable mode.898 899 After following the steps above, if the problem still persists and900 you think this is related to how setuptools handles editable installations,901 please submit a reproducible example902 (see https://stackoverflow.com/help/minimal-reproducible-example) to:903 904 https://github.com/pypa/setuptools/issues905 """906 _SEE_DOCS = "userguide/development_mode.html"907 