This commit is contained in:
ton
2024-10-07 10:13:40 +07:00
parent aa1631742f
commit 3a7d696db6
9729 changed files with 1832837 additions and 161742 deletions

View File

@@ -10,59 +10,41 @@ Create a wheel that, when installed, will make the source package 'editable'
*auxiliary build directory* or ``auxiliary_dir``.
"""
import logging
from __future__ import annotations
import io
import logging
import os
import shutil
import sys
import traceback
from contextlib import suppress
from enum import Enum
from inspect import cleandoc
from itertools import chain
from itertools import chain, starmap
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import (
TYPE_CHECKING,
Dict,
Iterable,
Iterator,
List,
Mapping,
Optional,
Tuple,
TypeVar,
Union,
)
from types import TracebackType
from typing import TYPE_CHECKING, Iterable, Iterator, Mapping, Protocol, TypeVar, cast
from .. import (
Command,
_normalization,
_path,
errors,
namespaces,
)
from .. import Command, _normalization, _path, errors, namespaces
from .._path import StrPath
from ..compat import py312
from ..discovery import find_package_path
from ..dist import Distribution
from ..warnings import (
InformationOnly,
SetuptoolsDeprecationWarning,
SetuptoolsWarning,
)
from ..warnings import InformationOnly, SetuptoolsDeprecationWarning, SetuptoolsWarning
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 wheel.wheelfile import WheelFile # noqa
from typing_extensions import Self
if sys.version_info >= (3, 8):
from typing import Protocol
elif TYPE_CHECKING:
from typing_extensions import Protocol
else:
from abc import ABC as Protocol
from .._vendor.wheel.wheelfile import WheelFile
_Path = Union[str, Path]
_P = TypeVar("_P", bound=_Path)
_P = TypeVar("_P", bound=StrPath)
_logger = logging.getLogger(__name__)
@@ -79,7 +61,7 @@ class _EditableMode(Enum):
COMPAT = "compat" # TODO: Remove `compat` after Dec/2022.
@classmethod
def convert(cls, mode: Optional[str]) -> "_EditableMode":
def convert(cls, mode: str | None) -> _EditableMode:
if not mode:
return _EditableMode.LENIENT # default
@@ -156,13 +138,14 @@ class editable_wheel(Command):
self._create_wheel_file(bdist_wheel)
except Exception:
traceback.print_exc()
# TODO: Fix false-positive [attr-defined] in typeshed
project = self.distribution.name or self.distribution.get_name()
_DebuggingTips.emit(project=project)
raise
def _ensure_dist_info(self):
if self.dist_info_dir is None:
dist_info = self.reinitialize_command("dist_info")
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()
@@ -181,13 +164,13 @@ class editable_wheel(Command):
installer = _NamespaceInstaller(dist, installation_dir, pth_prefix, src_root)
installer.install_namespaces()
def _find_egg_info_dir(self) -> Optional[str]:
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: _Path, build_lib: _Path, tmp_dir: _Path
self, name: str, unpacked_wheel: StrPath, build_lib: StrPath, tmp_dir: StrPath
):
"""Configure commands to behave in the following ways:
@@ -209,12 +192,18 @@ class editable_wheel(Command):
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 = dist.reinitialize_command("egg_info", reinit_subcommands=True)
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 = dist.reinitialize_command("build", reinit_subcommands=True)
install = dist.reinitialize_command("install", reinit_subcommands=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
@@ -222,12 +211,14 @@ class editable_wheel(Command):
install.install_headers = headers
install.install_data = data
install_scripts = dist.get_command_obj("install_scripts")
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 = dist.get_command_obj("build_py")
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()
@@ -240,6 +231,7 @@ class editable_wheel(Command):
"""Set the ``editable_mode`` flag in the build sub-commands"""
dist = self.distribution
build = dist.get_command_obj("build")
# TODO: Update typeshed distutils stubs to overload non-None return type by default
for cmd_name in build.get_sub_commands():
cmd = dist.get_command_obj(cmd_name)
if hasattr(cmd, "editable_mode"):
@@ -247,9 +239,9 @@ class editable_wheel(Command):
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] = {}
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():
@@ -262,8 +254,12 @@ class editable_wheel(Command):
return files, mapping
def _run_build_commands(
self, dist_name: str, unpacked_wheel: _Path, build_lib: _Path, tmp_dir: _Path
) -> Tuple[List[str], Dict[str, str]]:
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()
@@ -272,7 +268,7 @@ class editable_wheel(Command):
self._run_install("data")
return files, mapping
def _run_build_subcommands(self):
def _run_build_subcommands(self) -> None:
"""
Issue #3501 indicates that some plugins/customizations might rely on:
@@ -286,10 +282,10 @@ class editable_wheel(Command):
# TODO: Once plugins/customisations 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: Command = self.get_finalized_command("build")
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) != build_py_cls:
if name == "build_py" and type(cmd) is not build_py_cls:
self._safely_run(name)
else:
self.run_command(name)
@@ -360,8 +356,8 @@ class editable_wheel(Command):
self,
name: str,
tag: str,
build_lib: _Path,
) -> "EditableStrategy":
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)
@@ -384,28 +380,31 @@ class editable_wheel(Command):
class EditableStrategy(Protocol):
def __call__(self, wheel: "WheelFile", files: List[str], mapping: Dict[str, str]):
...
def __call__(self, wheel: WheelFile, files: list[str], mapping: dict[str, str]): ...
def __enter__(self):
...
def __enter__(self) -> Self: ...
def __exit__(self, _exc_type, _exc_value, _traceback):
...
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]):
def __init__(self, dist: Distribution, name: str, path_entries: list[Path]):
self.dist = dist
self.name = name
self.path_entries = path_entries
def __call__(self, wheel: "WheelFile", files: List[str], mapping: Dict[str, str]):
entries = "\n".join((str(p.resolve()) for p in self.path_entries))
def __call__(self, wheel: WheelFile, files: list[str], mapping: dict[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):
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}
@@ -413,8 +412,13 @@ class _StaticPth:
_logger.warning(msg + _LENIENT_WARNING)
return self
def __exit__(self, _exc_type, _exc_value, _traceback):
...
def __exit__(
self,
_exc_type: object,
_exc_value: object,
_traceback: object,
) -> None:
pass
class _LinkTree(_StaticPth):
@@ -432,19 +436,19 @@ class _LinkTree(_StaticPth):
self,
dist: Distribution,
name: str,
auxiliary_dir: _Path,
build_lib: _Path,
auxiliary_dir: StrPath,
build_lib: StrPath,
):
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: Dict[str, str]):
def __call__(self, wheel: WheelFile, files: list[str], mapping: dict[str, str]):
self._create_links(files, mapping)
super().__call__(wheel, files, mapping)
def _normalize_output(self, file: str) -> Optional[str]:
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)
@@ -460,8 +464,9 @@ class _LinkTree(_StaticPth):
def _create_links(self, outputs, output_mapping):
self.auxiliary_dir.mkdir(parents=True, exist_ok=True)
link_type = "sym" if _can_symlink_files(self.auxiliary_dir) else "hard"
mappings = {self._normalize_output(k): v for k, v in output_mapping.items()}
mappings.pop(None, None) # remove files that are not relative to build_lib
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)
@@ -471,12 +476,17 @@ class _LinkTree(_StaticPth):
for relative, src in mappings.items():
self._create_file(relative, src, link=link_type)
def __enter__(self):
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, _exc_value, _traceback):
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}
@@ -492,13 +502,13 @@ class _TopLevelFinder:
self.dist = dist
self.name = name
def __call__(self, wheel: "WheelFile", files: List[str], mapping: Dict[str, str]):
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[str, List[str]] = dict(
namespaces_: dict[str, list[str]] = dict(
chain(
_find_namespaces(self.dist.packages or [], roots),
((ns, []) for ns in _find_virtual_namespaces(roots)),
@@ -517,18 +527,32 @@ class _TopLevelFinder:
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")
wheel.writestr(f"{finder}.py", content)
yield (f"{finder}.py", content)
content = _encode_pth(f"import {finder}; {finder}.install()")
wheel.writestr(f"__editable__.{self.name}.pth", content)
yield (f"__editable__.{self.name}.pth", content)
def __enter__(self):
def __call__(self, wheel: WheelFile, files: list[str], mapping: dict[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, _exc_value, _traceback):
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.
@@ -537,17 +561,20 @@ class _TopLevelFinder:
def _encode_pth(content: str) -> bytes:
""".pth files are always read with 'locale' encoding, the recommendation
"""
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 behaviour 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)``).
``encoding=None``, not all of them use ``locale.getpreferredencoding(False)``
or ``locale.getencoding()``).
"""
encoding = "locale" if sys.version_info >= (3, 10) else None
with io.BytesIO() as buffer:
wrapper = io.TextIOWrapper(buffer, encoding)
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)
@@ -575,7 +602,7 @@ def _can_symlink_files(base_dir: Path) -> bool:
def _simple_layout(
packages: Iterable[str], package_dir: Dict[str, str], project_dir: Path
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**
@@ -608,7 +635,7 @@ def _simple_layout(
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([_parent_path(k, v) for k, v in layout.items()])
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()
@@ -657,9 +684,9 @@ def _find_top_level_modules(dist: Distribution) -> Iterator[str]:
def _find_package_roots(
packages: Iterable[str],
package_dir: Mapping[str, str],
src_root: _Path,
) -> Dict[str, str]:
pkg_roots: Dict[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)
}
@@ -667,7 +694,7 @@ def _find_package_roots(
return _remove_nested(pkg_roots)
def _absolute_root(path: _Path) -> str:
def _absolute_root(path: StrPath) -> str:
"""Works for packages and top-level modules"""
path_ = Path(path)
parent = path_.parent
@@ -678,7 +705,7 @@ def _absolute_root(path: _Path) -> str:
return str(parent.resolve() / path_.name)
def _find_virtual_namespaces(pkg_roots: Dict[str, str]) -> Iterator[str]:
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.
@@ -703,15 +730,15 @@ def _find_virtual_namespaces(pkg_roots: Dict[str, str]) -> Iterator[str]:
def _find_namespaces(
packages: List[str], pkg_roots: Dict[str, str]
) -> Iterator[Tuple[str, List[str]]]:
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]:
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())):
@@ -772,6 +799,7 @@ class _NamespaceInstaller(namespaces.Installer):
_FINDER_TEMPLATE = """\
from __future__ import annotations
import sys
from importlib.machinery import ModuleSpec, PathFinder
from importlib.machinery import all_suffixes as module_suffixes
@@ -779,16 +807,14 @@ from importlib.util import spec_from_file_location
from itertools import chain
from pathlib import Path
MAPPING = {mapping!r}
NAMESPACES = {namespaces!r}
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, path=None, target=None):
extra_path = []
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]
@@ -799,35 +825,42 @@ class _EditableFinder: # MetaPathFinder
# to the importlib.machinery implementation.
parent, _, child = fullname.rpartition(".")
if parent and parent in MAPPING:
return PathFinder.find_spec(fullname, path=[MAPPING[parent], *extra_path])
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, candidate_path):
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):
def _path_hook(cls, path) -> type[_EditableNamespaceFinder]:
if path == PATH_PLACEHOLDER:
return cls
raise ImportError
@classmethod
def _paths(cls, fullname):
# Ensure __path__ is not empty for the spec to be considered a namespace.
return NAMESPACES[fullname] or MAPPING.get(fullname) or [PATH_PLACEHOLDER]
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, target=None):
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)
@@ -835,7 +868,7 @@ class _EditableNamespaceFinder: # PathEntryFinder
return None
@classmethod
def find_module(cls, fullname):
def find_module(cls, _fullname) -> None:
return None
@@ -855,7 +888,7 @@ def install():
def _finder_template(
name: str, mapping: Mapping[str, str], namespaces: Dict[str, List[str]]
name: str, mapping: Mapping[str, str], namespaces: dict[str, list[str]]
) -> str:
"""Create a string containing the code for the``MetaPathFinder`` and
``PathEntryFinder``.