You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			909 lines
		
	
	
		
			34 KiB
		
	
	
	
		
			Python
		
	
			
		
		
	
	
			909 lines
		
	
	
		
			34 KiB
		
	
	
	
		
			Python
		
	
| """
 | |
| Create a wheel that, when installed, will make the source package 'editable'
 | |
| (add it to the interpreter's path, including metadata) per PEP 660. Replaces
 | |
| 'setup.py develop'.
 | |
| 
 | |
| .. note::
 | |
|    One of the mechanisms briefly mentioned in PEP 660 to implement editable installs is
 | |
|    to create a separated directory inside ``build`` and use a .pth file to point to that
 | |
|    directory. In the context of this file such directory is referred as
 | |
|    *auxiliary build directory* or ``auxiliary_dir``.
 | |
| """
 | |
| 
 | |
| from __future__ import annotations
 | |
| 
 | |
| import io
 | |
| import logging
 | |
| import os
 | |
| import shutil
 | |
| import traceback
 | |
| from collections.abc import Iterable, Iterator, Mapping
 | |
| from contextlib import suppress
 | |
| from enum import Enum
 | |
| from inspect import cleandoc
 | |
| from itertools import chain, starmap
 | |
| from pathlib import Path
 | |
| from tempfile import TemporaryDirectory
 | |
| from types import TracebackType
 | |
| from typing import TYPE_CHECKING, Protocol, TypeVar, cast
 | |
| 
 | |
| from .. import Command, _normalization, _path, _shutil, errors, namespaces
 | |
| from .._path import StrPath
 | |
| from ..compat import py310, py312
 | |
| from ..discovery import find_package_path
 | |
| from ..dist import Distribution
 | |
| from ..warnings import InformationOnly, SetuptoolsDeprecationWarning
 | |
| from .build import build as build_cls
 | |
| from .build_py import build_py as build_py_cls
 | |
| from .dist_info import dist_info as dist_info_cls
 | |
| from .egg_info import egg_info as egg_info_cls
 | |
| from .install import install as install_cls
 | |
| from .install_scripts import install_scripts as install_scripts_cls
 | |
| 
 | |
| if TYPE_CHECKING:
 | |
|     from typing_extensions import Self
 | |
| 
 | |
|     from .._vendor.wheel.wheelfile import WheelFile
 | |
| 
 | |
| _P = TypeVar("_P", bound=StrPath)
 | |
| _logger = logging.getLogger(__name__)
 | |
| 
 | |
| 
 | |
| class _EditableMode(Enum):
 | |
|     """
 | |
|     Possible editable installation modes:
 | |
|     `lenient` (new files automatically added to the package - DEFAULT);
 | |
|     `strict` (requires a new installation when files are added/removed); or
 | |
|     `compat` (attempts to emulate `python setup.py develop` - DEPRECATED).
 | |
|     """
 | |
| 
 | |
|     STRICT = "strict"
 | |
|     LENIENT = "lenient"
 | |
|     COMPAT = "compat"  # TODO: Remove `compat` after Dec/2022.
 | |
| 
 | |
|     @classmethod
 | |
|     def convert(cls, mode: str | None) -> _EditableMode:
 | |
|         if not mode:
 | |
|             return _EditableMode.LENIENT  # default
 | |
| 
 | |
|         _mode = mode.upper()
 | |
|         if _mode not in _EditableMode.__members__:
 | |
|             raise errors.OptionError(f"Invalid editable mode: {mode!r}. Try: 'strict'.")
 | |
| 
 | |
|         if _mode == "COMPAT":
 | |
|             SetuptoolsDeprecationWarning.emit(
 | |
|                 "Compat editable installs",
 | |
|                 """
 | |
|                 The 'compat' editable mode is transitional and will be removed
 | |
|                 in future versions of `setuptools`.
 | |
|                 Please adapt your code accordingly to use either the 'strict' or the
 | |
|                 'lenient' modes.
 | |
|                 """,
 | |
|                 see_docs="userguide/development_mode.html",
 | |
|                 # TODO: define due_date
 | |
|                 # There is a series of shortcomings with the available editable install
 | |
|                 # methods, and they are very controversial. This is something that still
 | |
|                 # needs work.
 | |
|                 # Moreover, `pip` is still hiding this warning, so users are not aware.
 | |
|             )
 | |
| 
 | |
|         return _EditableMode[_mode]
 | |
| 
 | |
| 
 | |
| _STRICT_WARNING = """
 | |
| New or renamed files may not be automatically picked up without a new installation.
 | |
| """
 | |
| 
 | |
| _LENIENT_WARNING = """
 | |
| Options like `package-data`, `include/exclude-package-data` or
 | |
| `packages.find.exclude/include` may have no effect.
 | |
| """
 | |
| 
 | |
| 
 | |
| class editable_wheel(Command):
 | |
|     """Build 'editable' wheel for development.
 | |
|     This command is private and reserved for internal use of setuptools,
 | |
|     users should rely on ``setuptools.build_meta`` APIs.
 | |
|     """
 | |
| 
 | |
|     description = "DO NOT CALL DIRECTLY, INTERNAL ONLY: create PEP 660 editable wheel"
 | |
| 
 | |
|     user_options = [
 | |
|         ("dist-dir=", "d", "directory to put final built distributions in"),
 | |
|         ("dist-info-dir=", "I", "path to a pre-build .dist-info directory"),
 | |
|         ("mode=", None, cleandoc(_EditableMode.__doc__ or "")),
 | |
|     ]
 | |
| 
 | |
|     def initialize_options(self):
 | |
|         self.dist_dir = None
 | |
|         self.dist_info_dir = None
 | |
|         self.project_dir = None
 | |
|         self.mode = None
 | |
| 
 | |
|     def finalize_options(self) -> None:
 | |
|         dist = self.distribution
 | |
|         self.project_dir = dist.src_root or os.curdir
 | |
|         self.package_dir = dist.package_dir or {}
 | |
|         self.dist_dir = Path(self.dist_dir or os.path.join(self.project_dir, "dist"))
 | |
| 
 | |
|     def run(self) -> None:
 | |
|         try:
 | |
|             self.dist_dir.mkdir(exist_ok=True)
 | |
|             self._ensure_dist_info()
 | |
| 
 | |
|             # Add missing dist_info files
 | |
|             self.reinitialize_command("bdist_wheel")
 | |
|             bdist_wheel = self.get_finalized_command("bdist_wheel")
 | |
|             bdist_wheel.write_wheelfile(self.dist_info_dir)
 | |
| 
 | |
|             self._create_wheel_file(bdist_wheel)
 | |
|         except Exception as ex:
 | |
|             project = self.distribution.name or self.distribution.get_name()
 | |
|             py310.add_note(
 | |
|                 ex,
 | |
|                 f"An error occurred when building editable wheel for {project}.\n"
 | |
|                 "See debugging tips in: "
 | |
|                 "https://setuptools.pypa.io/en/latest/userguide/development_mode.html#debugging-tips",
 | |
|             )
 | |
|             raise
 | |
| 
 | |
|     def _ensure_dist_info(self):
 | |
|         if self.dist_info_dir is None:
 | |
|             dist_info = cast(dist_info_cls, self.reinitialize_command("dist_info"))
 | |
|             dist_info.output_dir = self.dist_dir
 | |
|             dist_info.ensure_finalized()
 | |
|             dist_info.run()
 | |
|             self.dist_info_dir = dist_info.dist_info_dir
 | |
|         else:
 | |
|             assert str(self.dist_info_dir).endswith(".dist-info")
 | |
|             assert Path(self.dist_info_dir, "METADATA").exists()
 | |
| 
 | |
|     def _install_namespaces(self, installation_dir, pth_prefix):
 | |
|         # XXX: Only required to support the deprecated namespace practice
 | |
|         dist = self.distribution
 | |
|         if not dist.namespace_packages:
 | |
|             return
 | |
| 
 | |
|         src_root = Path(self.project_dir, self.package_dir.get("", ".")).resolve()
 | |
|         installer = _NamespaceInstaller(dist, installation_dir, pth_prefix, src_root)
 | |
|         installer.install_namespaces()
 | |
| 
 | |
|     def _find_egg_info_dir(self) -> str | None:
 | |
|         parent_dir = Path(self.dist_info_dir).parent if self.dist_info_dir else Path()
 | |
|         candidates = map(str, parent_dir.glob("*.egg-info"))
 | |
|         return next(candidates, None)
 | |
| 
 | |
|     def _configure_build(
 | |
|         self, name: str, unpacked_wheel: StrPath, build_lib: StrPath, tmp_dir: StrPath
 | |
|     ):
 | |
|         """Configure commands to behave in the following ways:
 | |
| 
 | |
|         - Build commands can write to ``build_lib`` if they really want to...
 | |
|           (but this folder is expected to be ignored and modules are expected to live
 | |
|           in the project directory...)
 | |
|         - Binary extensions should be built in-place (editable_mode = True)
 | |
|         - Data/header/script files are not part of the "editable" specification
 | |
|           so they are written directly to the unpacked_wheel directory.
 | |
|         """
 | |
|         # Non-editable files (data, headers, scripts) are written directly to the
 | |
|         # unpacked_wheel
 | |
| 
 | |
|         dist = self.distribution
 | |
|         wheel = str(unpacked_wheel)
 | |
|         build_lib = str(build_lib)
 | |
|         data = str(Path(unpacked_wheel, f"{name}.data", "data"))
 | |
|         headers = str(Path(unpacked_wheel, f"{name}.data", "headers"))
 | |
|         scripts = str(Path(unpacked_wheel, f"{name}.data", "scripts"))
 | |
| 
 | |
|         # egg-info may be generated again to create a manifest (used for package data)
 | |
|         egg_info = cast(
 | |
|             egg_info_cls, dist.reinitialize_command("egg_info", reinit_subcommands=True)
 | |
|         )
 | |
|         egg_info.egg_base = str(tmp_dir)
 | |
|         egg_info.ignore_egg_info_in_manifest = True
 | |
| 
 | |
|         build = cast(
 | |
|             build_cls, dist.reinitialize_command("build", reinit_subcommands=True)
 | |
|         )
 | |
|         install = cast(
 | |
|             install_cls, dist.reinitialize_command("install", reinit_subcommands=True)
 | |
|         )
 | |
| 
 | |
|         build.build_platlib = build.build_purelib = build.build_lib = build_lib
 | |
|         install.install_purelib = install.install_platlib = install.install_lib = wheel
 | |
|         install.install_scripts = build.build_scripts = scripts
 | |
|         install.install_headers = headers
 | |
|         install.install_data = data
 | |
| 
 | |
|         # For portability, ensure scripts are built with #!python shebang
 | |
|         # pypa/setuptools#4863
 | |
|         build_scripts = dist.get_command_obj("build_scripts")
 | |
|         build_scripts.executable = 'python'
 | |
| 
 | |
|         install_scripts = cast(
 | |
|             install_scripts_cls, dist.get_command_obj("install_scripts")
 | |
|         )
 | |
|         install_scripts.no_ep = True
 | |
| 
 | |
|         build.build_temp = str(tmp_dir)
 | |
| 
 | |
|         build_py = cast(build_py_cls, dist.get_command_obj("build_py"))
 | |
|         build_py.compile = False
 | |
|         build_py.existing_egg_info_dir = self._find_egg_info_dir()
 | |
| 
 | |
|         self._set_editable_mode()
 | |
| 
 | |
|         build.ensure_finalized()
 | |
|         install.ensure_finalized()
 | |
| 
 | |
|     def _set_editable_mode(self):
 | |
|         """Set the ``editable_mode`` flag in the build sub-commands"""
 | |
|         dist = self.distribution
 | |
|         build = dist.get_command_obj("build")
 | |
|         for cmd_name in build.get_sub_commands():
 | |
|             cmd = dist.get_command_obj(cmd_name)
 | |
|             if hasattr(cmd, "editable_mode"):
 | |
|                 cmd.editable_mode = True
 | |
|             elif hasattr(cmd, "inplace"):
 | |
|                 cmd.inplace = True  # backward compatibility with distutils
 | |
| 
 | |
|     def _collect_build_outputs(self) -> tuple[list[str], dict[str, str]]:
 | |
|         files: list[str] = []
 | |
|         mapping: dict[str, str] = {}
 | |
|         build = self.get_finalized_command("build")
 | |
| 
 | |
|         for cmd_name in build.get_sub_commands():
 | |
|             cmd = self.get_finalized_command(cmd_name)
 | |
|             if hasattr(cmd, "get_outputs"):
 | |
|                 files.extend(cmd.get_outputs() or [])
 | |
|             if hasattr(cmd, "get_output_mapping"):
 | |
|                 mapping.update(cmd.get_output_mapping() or {})
 | |
| 
 | |
|         return files, mapping
 | |
| 
 | |
|     def _run_build_commands(
 | |
|         self,
 | |
|         dist_name: str,
 | |
|         unpacked_wheel: StrPath,
 | |
|         build_lib: StrPath,
 | |
|         tmp_dir: StrPath,
 | |
|     ) -> tuple[list[str], dict[str, str]]:
 | |
|         self._configure_build(dist_name, unpacked_wheel, build_lib, tmp_dir)
 | |
|         self._run_build_subcommands()
 | |
|         files, mapping = self._collect_build_outputs()
 | |
|         self._run_install("headers")
 | |
|         self._run_install("scripts")
 | |
|         self._run_install("data")
 | |
|         return files, mapping
 | |
| 
 | |
|     def _run_build_subcommands(self) -> None:
 | |
|         """
 | |
|         Issue #3501 indicates that some plugins/customizations might rely on:
 | |
| 
 | |
|         1. ``build_py`` not running
 | |
|         2. ``build_py`` always copying files to ``build_lib``
 | |
| 
 | |
|         However both these assumptions may be false in editable_wheel.
 | |
|         This method implements a temporary workaround to support the ecosystem
 | |
|         while the implementations catch up.
 | |
|         """
 | |
|         # TODO: Once plugins/customizations had the chance to catch up, replace
 | |
|         #       `self._run_build_subcommands()` with `self.run_command("build")`.
 | |
|         #       Also remove _safely_run, TestCustomBuildPy. Suggested date: Aug/2023.
 | |
|         build = self.get_finalized_command("build")
 | |
|         for name in build.get_sub_commands():
 | |
|             cmd = self.get_finalized_command(name)
 | |
|             if name == "build_py" and type(cmd) is not build_py_cls:
 | |
|                 self._safely_run(name)
 | |
|             else:
 | |
|                 self.run_command(name)
 | |
| 
 | |
|     def _safely_run(self, cmd_name: str):
 | |
|         try:
 | |
|             return self.run_command(cmd_name)
 | |
|         except Exception:
 | |
|             SetuptoolsDeprecationWarning.emit(
 | |
|                 "Customization incompatible with editable install",
 | |
|                 f"""
 | |
|                 {traceback.format_exc()}
 | |
| 
 | |
|                 If you are seeing this warning it is very likely that a setuptools
 | |
|                 plugin or customization overrides the `{cmd_name}` command, without
 | |
|                 taking into consideration how editable installs run build steps
 | |
|                 starting from setuptools v64.0.0.
 | |
| 
 | |
|                 Plugin authors and developers relying on custom build steps are
 | |
|                 encouraged to update their `{cmd_name}` implementation considering the
 | |
|                 information about editable installs in
 | |
|                 https://setuptools.pypa.io/en/latest/userguide/extension.html.
 | |
| 
 | |
|                 For the time being `setuptools` will silence this error and ignore
 | |
|                 the faulty command, but this behavior will change in future versions.
 | |
|                 """,
 | |
|                 # TODO: define due_date
 | |
|                 # There is a series of shortcomings with the available editable install
 | |
|                 # methods, and they are very controversial. This is something that still
 | |
|                 # needs work.
 | |
|             )
 | |
| 
 | |
|     def _create_wheel_file(self, bdist_wheel):
 | |
|         from wheel.wheelfile import WheelFile
 | |
| 
 | |
|         dist_info = self.get_finalized_command("dist_info")
 | |
|         dist_name = dist_info.name
 | |
|         tag = "-".join(bdist_wheel.get_tag())
 | |
|         build_tag = "0.editable"  # According to PEP 427 needs to start with digit
 | |
|         archive_name = f"{dist_name}-{build_tag}-{tag}.whl"
 | |
|         wheel_path = Path(self.dist_dir, archive_name)
 | |
|         if wheel_path.exists():
 | |
|             wheel_path.unlink()
 | |
| 
 | |
|         unpacked_wheel = TemporaryDirectory(suffix=archive_name)
 | |
|         build_lib = TemporaryDirectory(suffix=".build-lib")
 | |
|         build_tmp = TemporaryDirectory(suffix=".build-temp")
 | |
| 
 | |
|         with unpacked_wheel as unpacked, build_lib as lib, build_tmp as tmp:
 | |
|             unpacked_dist_info = Path(unpacked, Path(self.dist_info_dir).name)
 | |
|             shutil.copytree(self.dist_info_dir, unpacked_dist_info)
 | |
|             self._install_namespaces(unpacked, dist_name)
 | |
|             files, mapping = self._run_build_commands(dist_name, unpacked, lib, tmp)
 | |
|             strategy = self._select_strategy(dist_name, tag, lib)
 | |
|             with strategy, WheelFile(wheel_path, "w") as wheel_obj:
 | |
|                 strategy(wheel_obj, files, mapping)
 | |
|                 wheel_obj.write_files(unpacked)
 | |
| 
 | |
|         return wheel_path
 | |
| 
 | |
|     def _run_install(self, category: str):
 | |
|         has_category = getattr(self.distribution, f"has_{category}", None)
 | |
|         if has_category and has_category():
 | |
|             _logger.info(f"Installing {category} as non editable")
 | |
|             self.run_command(f"install_{category}")
 | |
| 
 | |
|     def _select_strategy(
 | |
|         self,
 | |
|         name: str,
 | |
|         tag: str,
 | |
|         build_lib: StrPath,
 | |
|     ) -> EditableStrategy:
 | |
|         """Decides which strategy to use to implement an editable installation."""
 | |
|         build_name = f"__editable__.{name}-{tag}"
 | |
|         project_dir = Path(self.project_dir)
 | |
|         mode = _EditableMode.convert(self.mode)
 | |
| 
 | |
|         if mode is _EditableMode.STRICT:
 | |
|             auxiliary_dir = _empty_dir(Path(self.project_dir, "build", build_name))
 | |
|             return _LinkTree(self.distribution, name, auxiliary_dir, build_lib)
 | |
| 
 | |
|         packages = _find_packages(self.distribution)
 | |
|         has_simple_layout = _simple_layout(packages, self.package_dir, project_dir)
 | |
|         is_compat_mode = mode is _EditableMode.COMPAT
 | |
|         if set(self.package_dir) == {""} and has_simple_layout or is_compat_mode:
 | |
|             # src-layout(ish) is relatively safe for a simple pth file
 | |
|             src_dir = self.package_dir.get("", ".")
 | |
|             return _StaticPth(self.distribution, name, [Path(project_dir, src_dir)])
 | |
| 
 | |
|         # Use a MetaPathFinder to avoid adding accidental top-level packages/modules
 | |
|         return _TopLevelFinder(self.distribution, name)
 | |
| 
 | |
| 
 | |
| class EditableStrategy(Protocol):
 | |
|     def __call__(
 | |
|         self, wheel: WheelFile, files: list[str], mapping: Mapping[str, str]
 | |
|     ) -> object: ...
 | |
|     def __enter__(self) -> Self: ...
 | |
|     def __exit__(
 | |
|         self,
 | |
|         _exc_type: type[BaseException] | None,
 | |
|         _exc_value: BaseException | None,
 | |
|         _traceback: TracebackType | None,
 | |
|     ) -> object: ...
 | |
| 
 | |
| 
 | |
| class _StaticPth:
 | |
|     def __init__(self, dist: Distribution, name: str, path_entries: list[Path]) -> None:
 | |
|         self.dist = dist
 | |
|         self.name = name
 | |
|         self.path_entries = path_entries
 | |
| 
 | |
|     def __call__(self, wheel: WheelFile, files: list[str], mapping: Mapping[str, str]):
 | |
|         entries = "\n".join(str(p.resolve()) for p in self.path_entries)
 | |
|         contents = _encode_pth(f"{entries}\n")
 | |
|         wheel.writestr(f"__editable__.{self.name}.pth", contents)
 | |
| 
 | |
|     def __enter__(self) -> Self:
 | |
|         msg = f"""
 | |
|         Editable install will be performed using .pth file to extend `sys.path` with:
 | |
|         {list(map(os.fspath, self.path_entries))!r}
 | |
|         """
 | |
|         _logger.warning(msg + _LENIENT_WARNING)
 | |
|         return self
 | |
| 
 | |
|     def __exit__(
 | |
|         self,
 | |
|         _exc_type: object,
 | |
|         _exc_value: object,
 | |
|         _traceback: object,
 | |
|     ) -> None:
 | |
|         pass
 | |
| 
 | |
| 
 | |
| class _LinkTree(_StaticPth):
 | |
|     """
 | |
|     Creates a ``.pth`` file that points to a link tree in the ``auxiliary_dir``.
 | |
| 
 | |
|     This strategy will only link files (not dirs), so it can be implemented in
 | |
|     any OS, even if that means using hardlinks instead of symlinks.
 | |
| 
 | |
|     By collocating ``auxiliary_dir`` and the original source code, limitations
 | |
|     with hardlinks should be avoided.
 | |
|     """
 | |
| 
 | |
|     def __init__(
 | |
|         self,
 | |
|         dist: Distribution,
 | |
|         name: str,
 | |
|         auxiliary_dir: StrPath,
 | |
|         build_lib: StrPath,
 | |
|     ) -> None:
 | |
|         self.auxiliary_dir = Path(auxiliary_dir)
 | |
|         self.build_lib = Path(build_lib).resolve()
 | |
|         self._file = dist.get_command_obj("build_py").copy_file
 | |
|         super().__init__(dist, name, [self.auxiliary_dir])
 | |
| 
 | |
|     def __call__(self, wheel: WheelFile, files: list[str], mapping: Mapping[str, str]):
 | |
|         self._create_links(files, mapping)
 | |
|         super().__call__(wheel, files, mapping)
 | |
| 
 | |
|     def _normalize_output(self, file: str) -> str | None:
 | |
|         # Files relative to build_lib will be normalized to None
 | |
|         with suppress(ValueError):
 | |
|             path = Path(file).resolve().relative_to(self.build_lib)
 | |
|             return str(path).replace(os.sep, '/')
 | |
|         return None
 | |
| 
 | |
|     def _create_file(self, relative_output: str, src_file: str, link=None):
 | |
|         dest = self.auxiliary_dir / relative_output
 | |
|         if not dest.parent.is_dir():
 | |
|             dest.parent.mkdir(parents=True)
 | |
|         self._file(src_file, dest, link=link)
 | |
| 
 | |
|     def _create_links(self, outputs, output_mapping: Mapping[str, str]):
 | |
|         self.auxiliary_dir.mkdir(parents=True, exist_ok=True)
 | |
|         link_type = "sym" if _can_symlink_files(self.auxiliary_dir) else "hard"
 | |
|         normalised = ((self._normalize_output(k), v) for k, v in output_mapping.items())
 | |
|         # remove files that are not relative to build_lib
 | |
|         mappings = {k: v for k, v in normalised if k is not None}
 | |
| 
 | |
|         for output in outputs:
 | |
|             relative = self._normalize_output(output)
 | |
|             if relative and relative not in mappings:
 | |
|                 self._create_file(relative, output)
 | |
| 
 | |
|         for relative, src in mappings.items():
 | |
|             self._create_file(relative, src, link=link_type)
 | |
| 
 | |
|     def __enter__(self) -> Self:
 | |
|         msg = "Strict editable install will be performed using a link tree.\n"
 | |
|         _logger.warning(msg + _STRICT_WARNING)
 | |
|         return self
 | |
| 
 | |
|     def __exit__(
 | |
|         self,
 | |
|         _exc_type: object,
 | |
|         _exc_value: object,
 | |
|         _traceback: object,
 | |
|     ) -> None:
 | |
|         msg = f"""\n
 | |
|         Strict editable installation performed using the auxiliary directory:
 | |
|             {self.auxiliary_dir}
 | |
| 
 | |
|         Please be careful to not remove this directory, otherwise you might not be able
 | |
|         to import/use your package.
 | |
|         """
 | |
|         InformationOnly.emit("Editable installation.", msg)
 | |
| 
 | |
| 
 | |
| class _TopLevelFinder:
 | |
|     def __init__(self, dist: Distribution, name: str) -> None:
 | |
|         self.dist = dist
 | |
|         self.name = name
 | |
| 
 | |
|     def template_vars(self) -> tuple[str, str, dict[str, str], dict[str, list[str]]]:
 | |
|         src_root = self.dist.src_root or os.curdir
 | |
|         top_level = chain(_find_packages(self.dist), _find_top_level_modules(self.dist))
 | |
|         package_dir = self.dist.package_dir or {}
 | |
|         roots = _find_package_roots(top_level, package_dir, src_root)
 | |
| 
 | |
|         namespaces_ = dict(
 | |
|             chain(
 | |
|                 _find_namespaces(self.dist.packages or [], roots),
 | |
|                 ((ns, []) for ns in _find_virtual_namespaces(roots)),
 | |
|             )
 | |
|         )
 | |
| 
 | |
|         legacy_namespaces = {
 | |
|             pkg: find_package_path(pkg, roots, self.dist.src_root or "")
 | |
|             for pkg in self.dist.namespace_packages or []
 | |
|         }
 | |
| 
 | |
|         mapping = {**roots, **legacy_namespaces}
 | |
|         # ^-- We need to explicitly add the legacy_namespaces to the mapping to be
 | |
|         #     able to import their modules even if another package sharing the same
 | |
|         #     namespace is installed in a conventional (non-editable) way.
 | |
| 
 | |
|         name = f"__editable__.{self.name}.finder"
 | |
|         finder = _normalization.safe_identifier(name)
 | |
|         return finder, name, mapping, namespaces_
 | |
| 
 | |
|     def get_implementation(self) -> Iterator[tuple[str, bytes]]:
 | |
|         finder, name, mapping, namespaces_ = self.template_vars()
 | |
| 
 | |
|         content = bytes(_finder_template(name, mapping, namespaces_), "utf-8")
 | |
|         yield (f"{finder}.py", content)
 | |
| 
 | |
|         content = _encode_pth(f"import {finder}; {finder}.install()")
 | |
|         yield (f"__editable__.{self.name}.pth", content)
 | |
| 
 | |
|     def __call__(self, wheel: WheelFile, files: list[str], mapping: Mapping[str, str]):
 | |
|         for file, content in self.get_implementation():
 | |
|             wheel.writestr(file, content)
 | |
| 
 | |
|     def __enter__(self) -> Self:
 | |
|         msg = "Editable install will be performed using a meta path finder.\n"
 | |
|         _logger.warning(msg + _LENIENT_WARNING)
 | |
|         return self
 | |
| 
 | |
|     def __exit__(
 | |
|         self,
 | |
|         _exc_type: object,
 | |
|         _exc_value: object,
 | |
|         _traceback: object,
 | |
|     ) -> None:
 | |
|         msg = """\n
 | |
|         Please be careful with folders in your working directory with the same
 | |
|         name as your package as they may take precedence during imports.
 | |
|         """
 | |
|         InformationOnly.emit("Editable installation.", msg)
 | |
| 
 | |
| 
 | |
| def _encode_pth(content: str) -> bytes:
 | |
|     """
 | |
|     Prior to Python 3.13 (see https://github.com/python/cpython/issues/77102),
 | |
|     .pth files are always read with 'locale' encoding, the recommendation
 | |
|     from the cpython core developers is to write them as ``open(path, "w")``
 | |
|     and ignore warnings (see python/cpython#77102, pypa/setuptools#3937).
 | |
|     This function tries to simulate this behavior without having to create an
 | |
|     actual file, in a way that supports a range of active Python versions.
 | |
|     (There seems to be some variety in the way different version of Python handle
 | |
|     ``encoding=None``, not all of them use ``locale.getpreferredencoding(False)``
 | |
|     or ``locale.getencoding()``).
 | |
|     """
 | |
|     with io.BytesIO() as buffer:
 | |
|         wrapper = io.TextIOWrapper(buffer, encoding=py312.PTH_ENCODING)
 | |
|         # TODO: Python 3.13 replace the whole function with `bytes(content, "utf-8")`
 | |
|         wrapper.write(content)
 | |
|         wrapper.flush()
 | |
|         buffer.seek(0)
 | |
|         return buffer.read()
 | |
| 
 | |
| 
 | |
| def _can_symlink_files(base_dir: Path) -> bool:
 | |
|     with TemporaryDirectory(dir=str(base_dir.resolve())) as tmp:
 | |
|         path1, path2 = Path(tmp, "file1.txt"), Path(tmp, "file2.txt")
 | |
|         path1.write_text("file1", encoding="utf-8")
 | |
|         with suppress(AttributeError, NotImplementedError, OSError):
 | |
|             os.symlink(path1, path2)
 | |
|             if path2.is_symlink() and path2.read_text(encoding="utf-8") == "file1":
 | |
|                 return True
 | |
| 
 | |
|         try:
 | |
|             os.link(path1, path2)  # Ensure hard links can be created
 | |
|         except Exception as ex:
 | |
|             msg = (
 | |
|                 "File system does not seem to support either symlinks or hard links. "
 | |
|                 "Strict editable installs require one of them to be supported."
 | |
|             )
 | |
|             raise LinksNotSupported(msg) from ex
 | |
|         return False
 | |
| 
 | |
| 
 | |
| def _simple_layout(
 | |
|     packages: Iterable[str], package_dir: dict[str, str], project_dir: StrPath
 | |
| ) -> bool:
 | |
|     """Return ``True`` if:
 | |
|     - all packages are contained by the same parent directory, **and**
 | |
|     - all packages become importable if the parent directory is added to ``sys.path``.
 | |
| 
 | |
|     >>> _simple_layout(['a'], {"": "src"}, "/tmp/myproj")
 | |
|     True
 | |
|     >>> _simple_layout(['a', 'a.b'], {"": "src"}, "/tmp/myproj")
 | |
|     True
 | |
|     >>> _simple_layout(['a', 'a.b'], {}, "/tmp/myproj")
 | |
|     True
 | |
|     >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"": "src"}, "/tmp/myproj")
 | |
|     True
 | |
|     >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a": "a", "b": "b"}, ".")
 | |
|     True
 | |
|     >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a": "_a", "b": "_b"}, ".")
 | |
|     False
 | |
|     >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a": "_a"}, "/tmp/myproj")
 | |
|     False
 | |
|     >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a.a1.a2": "_a2"}, ".")
 | |
|     False
 | |
|     >>> _simple_layout(['a', 'a.b'], {"": "src", "a.b": "_ab"}, "/tmp/myproj")
 | |
|     False
 | |
|     >>> # Special cases, no packages yet:
 | |
|     >>> _simple_layout([], {"": "src"}, "/tmp/myproj")
 | |
|     True
 | |
|     >>> _simple_layout([], {"a": "_a", "": "src"}, "/tmp/myproj")
 | |
|     False
 | |
|     """
 | |
|     layout = {pkg: find_package_path(pkg, package_dir, project_dir) for pkg in packages}
 | |
|     if not layout:
 | |
|         return set(package_dir) in ({}, {""})
 | |
|     parent = os.path.commonpath(starmap(_parent_path, layout.items()))
 | |
|     return all(
 | |
|         _path.same_path(Path(parent, *key.split('.')), value)
 | |
|         for key, value in layout.items()
 | |
|     )
 | |
| 
 | |
| 
 | |
| def _parent_path(pkg, pkg_path):
 | |
|     """Infer the parent path containing a package, that if added to ``sys.path`` would
 | |
|     allow importing that package.
 | |
|     When ``pkg`` is directly mapped into a directory with a different name, return its
 | |
|     own path.
 | |
|     >>> _parent_path("a", "src/a")
 | |
|     'src'
 | |
|     >>> _parent_path("b", "src/c")
 | |
|     'src/c'
 | |
|     """
 | |
|     parent = pkg_path[: -len(pkg)] if pkg_path.endswith(pkg) else pkg_path
 | |
|     return parent.rstrip("/" + os.sep)
 | |
| 
 | |
| 
 | |
| def _find_packages(dist: Distribution) -> Iterator[str]:
 | |
|     yield from iter(dist.packages or [])
 | |
| 
 | |
|     py_modules = dist.py_modules or []
 | |
|     nested_modules = [mod for mod in py_modules if "." in mod]
 | |
|     if dist.ext_package:
 | |
|         yield dist.ext_package
 | |
|     else:
 | |
|         ext_modules = dist.ext_modules or []
 | |
|         nested_modules += [x.name for x in ext_modules if "." in x.name]
 | |
| 
 | |
|     for module in nested_modules:
 | |
|         package, _, _ = module.rpartition(".")
 | |
|         yield package
 | |
| 
 | |
| 
 | |
| def _find_top_level_modules(dist: Distribution) -> Iterator[str]:
 | |
|     py_modules = dist.py_modules or []
 | |
|     yield from (mod for mod in py_modules if "." not in mod)
 | |
| 
 | |
|     if not dist.ext_package:
 | |
|         ext_modules = dist.ext_modules or []
 | |
|         yield from (x.name for x in ext_modules if "." not in x.name)
 | |
| 
 | |
| 
 | |
| def _find_package_roots(
 | |
|     packages: Iterable[str],
 | |
|     package_dir: Mapping[str, str],
 | |
|     src_root: StrPath,
 | |
| ) -> dict[str, str]:
 | |
|     pkg_roots: dict[str, str] = {
 | |
|         pkg: _absolute_root(find_package_path(pkg, package_dir, src_root))
 | |
|         for pkg in sorted(packages)
 | |
|     }
 | |
| 
 | |
|     return _remove_nested(pkg_roots)
 | |
| 
 | |
| 
 | |
| def _absolute_root(path: StrPath) -> str:
 | |
|     """Works for packages and top-level modules"""
 | |
|     path_ = Path(path)
 | |
|     parent = path_.parent
 | |
| 
 | |
|     if path_.exists():
 | |
|         return str(path_.resolve())
 | |
|     else:
 | |
|         return str(parent.resolve() / path_.name)
 | |
| 
 | |
| 
 | |
| def _find_virtual_namespaces(pkg_roots: dict[str, str]) -> Iterator[str]:
 | |
|     """By carefully designing ``package_dir``, it is possible to implement the logical
 | |
|     structure of PEP 420 in a package without the corresponding directories.
 | |
| 
 | |
|     Moreover a parent package can be purposefully/accidentally skipped in the discovery
 | |
|     phase (e.g. ``find_packages(include=["mypkg.*"])``, when ``mypkg.foo`` is included
 | |
|     by ``mypkg`` itself is not).
 | |
|     We consider this case to also be a virtual namespace (ignoring the original
 | |
|     directory) to emulate a non-editable installation.
 | |
| 
 | |
|     This function will try to find these kinds of namespaces.
 | |
|     """
 | |
|     for pkg in pkg_roots:
 | |
|         if "." not in pkg:
 | |
|             continue
 | |
|         parts = pkg.split(".")
 | |
|         for i in range(len(parts) - 1, 0, -1):
 | |
|             partial_name = ".".join(parts[:i])
 | |
|             path = Path(find_package_path(partial_name, pkg_roots, ""))
 | |
|             if not path.exists() or partial_name not in pkg_roots:
 | |
|                 # partial_name not in pkg_roots ==> purposefully/accidentally skipped
 | |
|                 yield partial_name
 | |
| 
 | |
| 
 | |
| def _find_namespaces(
 | |
|     packages: list[str], pkg_roots: dict[str, str]
 | |
| ) -> Iterator[tuple[str, list[str]]]:
 | |
|     for pkg in packages:
 | |
|         path = find_package_path(pkg, pkg_roots, "")
 | |
|         if Path(path).exists() and not Path(path, "__init__.py").exists():
 | |
|             yield (pkg, [path])
 | |
| 
 | |
| 
 | |
| def _remove_nested(pkg_roots: dict[str, str]) -> dict[str, str]:
 | |
|     output = dict(pkg_roots.copy())
 | |
| 
 | |
|     for pkg, path in reversed(list(pkg_roots.items())):
 | |
|         if any(
 | |
|             pkg != other and _is_nested(pkg, path, other, other_path)
 | |
|             for other, other_path in pkg_roots.items()
 | |
|         ):
 | |
|             output.pop(pkg)
 | |
| 
 | |
|     return output
 | |
| 
 | |
| 
 | |
| def _is_nested(pkg: str, pkg_path: str, parent: str, parent_path: str) -> bool:
 | |
|     """
 | |
|     Return ``True`` if ``pkg`` is nested inside ``parent`` both logically and in the
 | |
|     file system.
 | |
|     >>> _is_nested("a.b", "path/a/b", "a", "path/a")
 | |
|     True
 | |
|     >>> _is_nested("a.b", "path/a/b", "a", "otherpath/a")
 | |
|     False
 | |
|     >>> _is_nested("a.b", "path/a/b", "c", "path/c")
 | |
|     False
 | |
|     >>> _is_nested("a.a", "path/a/a", "a", "path/a")
 | |
|     True
 | |
|     >>> _is_nested("b.a", "path/b/a", "a", "path/a")
 | |
|     False
 | |
|     """
 | |
|     norm_pkg_path = _path.normpath(pkg_path)
 | |
|     rest = pkg.replace(parent, "", 1).strip(".").split(".")
 | |
|     return pkg.startswith(parent) and norm_pkg_path == _path.normpath(
 | |
|         Path(parent_path, *rest)
 | |
|     )
 | |
| 
 | |
| 
 | |
| def _empty_dir(dir_: _P) -> _P:
 | |
|     """Create a directory ensured to be empty. Existing files may be removed."""
 | |
|     _shutil.rmtree(dir_, ignore_errors=True)
 | |
|     os.makedirs(dir_)
 | |
|     return dir_
 | |
| 
 | |
| 
 | |
| class _NamespaceInstaller(namespaces.Installer):
 | |
|     def __init__(self, distribution, installation_dir, editable_name, src_root) -> None:
 | |
|         self.distribution = distribution
 | |
|         self.src_root = src_root
 | |
|         self.installation_dir = installation_dir
 | |
|         self.editable_name = editable_name
 | |
|         self.outputs: list[str] = []
 | |
|         self.dry_run = False
 | |
| 
 | |
|     def _get_nspkg_file(self):
 | |
|         """Installation target."""
 | |
|         return os.path.join(self.installation_dir, self.editable_name + self.nspkg_ext)
 | |
| 
 | |
|     def _get_root(self):
 | |
|         """Where the modules/packages should be loaded from."""
 | |
|         return repr(str(self.src_root))
 | |
| 
 | |
| 
 | |
| _FINDER_TEMPLATE = """\
 | |
| from __future__ import annotations
 | |
| import sys
 | |
| from importlib.machinery import ModuleSpec, PathFinder
 | |
| from importlib.machinery import all_suffixes as module_suffixes
 | |
| from importlib.util import spec_from_file_location
 | |
| from itertools import chain
 | |
| from pathlib import Path
 | |
| 
 | |
| MAPPING: dict[str, str] = {mapping!r}
 | |
| NAMESPACES: dict[str, list[str]] = {namespaces!r}
 | |
| PATH_PLACEHOLDER = {name!r} + ".__path_hook__"
 | |
| 
 | |
| 
 | |
| class _EditableFinder:  # MetaPathFinder
 | |
|     @classmethod
 | |
|     def find_spec(cls, fullname: str, path=None, target=None) -> ModuleSpec | None:  # type: ignore
 | |
|         # Top-level packages and modules (we know these exist in the FS)
 | |
|         if fullname in MAPPING:
 | |
|             pkg_path = MAPPING[fullname]
 | |
|             return cls._find_spec(fullname, Path(pkg_path))
 | |
| 
 | |
|         # Handle immediate children modules (required for namespaces to work)
 | |
|         # To avoid problems with case sensitivity in the file system we delegate
 | |
|         # to the importlib.machinery implementation.
 | |
|         parent, _, child = fullname.rpartition(".")
 | |
|         if parent and parent in MAPPING:
 | |
|             return PathFinder.find_spec(fullname, path=[MAPPING[parent]])
 | |
| 
 | |
|         # Other levels of nesting should be handled automatically by importlib
 | |
|         # using the parent path.
 | |
|         return None
 | |
| 
 | |
|     @classmethod
 | |
|     def _find_spec(cls, fullname: str, candidate_path: Path) -> ModuleSpec | None:
 | |
|         init = candidate_path / "__init__.py"
 | |
|         candidates = (candidate_path.with_suffix(x) for x in module_suffixes())
 | |
|         for candidate in chain([init], candidates):
 | |
|             if candidate.exists():
 | |
|                 return spec_from_file_location(fullname, candidate)
 | |
|         return None
 | |
| 
 | |
| 
 | |
| class _EditableNamespaceFinder:  # PathEntryFinder
 | |
|     @classmethod
 | |
|     def _path_hook(cls, path) -> type[_EditableNamespaceFinder]:
 | |
|         if path == PATH_PLACEHOLDER:
 | |
|             return cls
 | |
|         raise ImportError
 | |
| 
 | |
|     @classmethod
 | |
|     def _paths(cls, fullname: str) -> list[str]:
 | |
|         paths = NAMESPACES[fullname]
 | |
|         if not paths and fullname in MAPPING:
 | |
|             paths = [MAPPING[fullname]]
 | |
|         # Always add placeholder, for 2 reasons:
 | |
|         # 1. __path__ cannot be empty for the spec to be considered namespace.
 | |
|         # 2. In the case of nested namespaces, we need to force
 | |
|         #    import machinery to query _EditableNamespaceFinder again.
 | |
|         return [*paths, PATH_PLACEHOLDER]
 | |
| 
 | |
|     @classmethod
 | |
|     def find_spec(cls, fullname: str, target=None) -> ModuleSpec | None:  # type: ignore
 | |
|         if fullname in NAMESPACES:
 | |
|             spec = ModuleSpec(fullname, None, is_package=True)
 | |
|             spec.submodule_search_locations = cls._paths(fullname)
 | |
|             return spec
 | |
|         return None
 | |
| 
 | |
|     @classmethod
 | |
|     def find_module(cls, _fullname) -> None:
 | |
|         return None
 | |
| 
 | |
| 
 | |
| def install():
 | |
|     if not any(finder == _EditableFinder for finder in sys.meta_path):
 | |
|         sys.meta_path.append(_EditableFinder)
 | |
| 
 | |
|     if not NAMESPACES:
 | |
|         return
 | |
| 
 | |
|     if not any(hook == _EditableNamespaceFinder._path_hook for hook in sys.path_hooks):
 | |
|         # PathEntryFinder is needed to create NamespaceSpec without private APIS
 | |
|         sys.path_hooks.append(_EditableNamespaceFinder._path_hook)
 | |
|     if PATH_PLACEHOLDER not in sys.path:
 | |
|         sys.path.append(PATH_PLACEHOLDER)  # Used just to trigger the path hook
 | |
| """
 | |
| 
 | |
| 
 | |
| def _finder_template(
 | |
|     name: str, mapping: Mapping[str, str], namespaces: dict[str, list[str]]
 | |
| ) -> str:
 | |
|     """Create a string containing the code for the``MetaPathFinder`` and
 | |
|     ``PathEntryFinder``.
 | |
|     """
 | |
|     mapping = dict(sorted(mapping.items(), key=lambda p: p[0]))
 | |
|     return _FINDER_TEMPLATE.format(name=name, mapping=mapping, namespaces=namespaces)
 | |
| 
 | |
| 
 | |
| class LinksNotSupported(errors.FileError):
 | |
|     """File system does not seem to support either symlinks or hard links."""
 |