Skip to content

Commit

Permalink
Reorganise editable_wheel to allow different strategies
Browse files Browse the repository at this point in the history
  • Loading branch information
abravalheri committed Apr 11, 2022
1 parent 372b617 commit c46526a
Showing 1 changed file with 111 additions and 32 deletions.
143 changes: 111 additions & 32 deletions setuptools/command/editable_wheel.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
"""
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'. Based on the setuptools develop command.
'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_build_dir``.
"""

import os
import shutil
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Dict, Iterable, Iterator, List, Union

from setuptools import Command, namespaces
from setuptools.discovery import find_package_path
from setuptools.dist import Distribution

from setuptools import Command
from setuptools import namespaces
_Path = Union[str, Path]


class editable_wheel(Command):
Expand All @@ -35,14 +45,10 @@ def initialize_options(self):
def finalize_options(self):
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"))
self.dist_dir.mkdir(exist_ok=True)

@property
def target(self):
package_dir = self.distribution.package_dir or {}
return _normalize_path(package_dir.get("") or self.project_dir)

def run(self):
self._ensure_dist_info()

Expand Down Expand Up @@ -73,7 +79,8 @@ def _install_namespaces(self, installation_dir, pth_prefix):
if not dist.namespace_packages:
return

installer = _NamespaceInstaller(dist, installation_dir, pth_prefix, self.target)
target = Path(self.project_dir, self.pakcage_dir.get("", ".")).resolve()
installer = _NamespaceInstaller(dist, installation_dir, pth_prefix, target)
installer.install_namespaces()

def _create_wheel_file(self, bdist_wheel):
Expand All @@ -94,32 +101,111 @@ def _create_wheel_file(self, bdist_wheel):
tmp_dist_info = Path(tmp, Path(self.dist_info_dir).name)
shutil.copytree(self.dist_info_dir, tmp_dist_info)
self._install_namespaces(tmp, editable_name)
self._populate_wheel(editable_name, tmp)
populate = self._populate_strategy(editable_name, tag)
populate(tmp)
with WheelFile(wheel_path, "w") as wf:
wf.write_files(tmp)

return wheel_path

def _best_strategy(self):
if self.strict:
return self._link_tree

def _populate_strategy(self, name, tag):
"""Decides which strategy to use to implement an editable installation."""
dist = self.distribution
if set(dist.packages) == {""}:
build_name = f"__editable__.{name}-{tag}"
project_dir = Path(self.project_dir)
auxiliar_build_dir = Path(self.project_dir, "build", build_name)

if self.strict:
# The LinkTree strategy will only link files, so it can be implemented in
# any OS, even if that means using hardlinks instead of symlinks
auxiliar_build_dir = _empty_dir(auxiliar_build_dir)
# TODO: return _LinkTree(dist, name, auxiliar_build_dir)
raise NotImplementedError

packages = _find_packages(dist)
has_simple_layout = _simple_layout(packages, self.package_dir, project_dir)
if set(self.package_dir) == {""} and has_simple_layout:
# src-layout(ish) package detected. These kind of packages are relatively
# safe so we can simply add the src directory to the pth file.
return self._top_level_pth

if self._can_symlink():
return self._top_level_symlinks
return _StaticPth(dist, name, [Path(project_dir, self.package_dir[""])])

# >>> msg = "TODO: Explain limitations with meta path finder"
# >>> warnings.warn(msg)
paths = [Path(project_dir, p) for p in (".", self.package_dir.get("")) if p]
# TODO: return _TopLevelFinder(dist, name, auxiliar_build_dir)
return _StaticPth(dist, name, paths)


class _StaticPth:
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, unpacked_wheel_dir: Path):
pth = Path(unpacked_wheel_dir, f"__editable__.{self.name}.pth")
entries = "\n".join((str(p.resolve()) for p in self.path_entries))
pth.write_text(f"{entries}\n", encoding="utf-8")


def _simple_layout(
packages: Iterable[str], package_dir: Dict[str, str], project_dir: Path
) -> bool:
"""Make sure all packages are contained by the same parent directory.
>>> _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": "_a", "a.a1.a2": "_a2", "b": "_b"},
... ".",
... )
False
"""
layout = {
pkg: find_package_path(pkg, package_dir, project_dir)
for pkg in packages
}
parent = os.path.commonpath(list(layout.values()))
return all(
_normalize_path(Path(parent, *key.split('.'))) == _normalize_path(value)
for key, value in layout.items()
)


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 _normalize_path(filename: _Path) -> str:
"""Normalize a file/dir name for comparison purposes"""
# See pkg_resources.normalize_path
file = os.path.abspath(filename) if sys.platform == 'cygwin' else filename
return os.path.normcase(os.path.realpath(os.path.normpath(file)))

# >>> def _targets(self):
# >>> build_py.find_modules()
# >>> self.dist.packages

def _populate_wheel(self, dist_id, unpacked_wheel_dir):
pth = Path(unpacked_wheel_dir, f"__editable__.{dist_id}.pth")
pth.write_text(f"{self.target}\n", encoding="utf-8")
def _empty_dir(dir_: Path) -> Path:
shutil.rmtree(dir_, ignore_errors=True)
dir_.mkdir()
return dir_


class _NamespaceInstaller(namespaces.Installer):
Expand All @@ -137,10 +223,3 @@ def _get_target(self):
def _get_root(self):
"""Where the modules/packages should be loaded from."""
return repr(str(self.src_root))


def _normalize_path(filename):
"""Normalize a file/dir name for comparison purposes"""
# See pkg_resources.normalize_path
file = os.path.abspath(filename) if sys.platform == 'cygwin' else filename
return os.path.normcase(os.path.realpath(os.path.normpath(file)))

0 comments on commit c46526a

Please sign in to comment.