diff options
| author | Anderson Bravalheri <andersonbravalheri@gmail.com> | 2022-04-09 22:55:12 +0100 |
|---|---|---|
| committer | Anderson Bravalheri <andersonbravalheri@gmail.com> | 2022-06-15 16:43:50 +0100 |
| commit | 994ca214cb0d9f01f72694758ddfe93cba0e26c5 (patch) | |
| tree | 6693c78eea208f08e7d9cc3f45459c706a90b06f /setuptools/command/editable_wheel.py | |
| parent | f210f161cf01648168fec05ed69d776f0ebbb156 (diff) | |
| download | python-setuptools-git-994ca214cb0d9f01f72694758ddfe93cba0e26c5.tar.gz | |
Add editable strategy with MetaPathFinder for top-level packages
Diffstat (limited to 'setuptools/command/editable_wheel.py')
| -rw-r--r-- | setuptools/command/editable_wheel.py | 168 |
1 files changed, 146 insertions, 22 deletions
diff --git a/setuptools/command/editable_wheel.py b/setuptools/command/editable_wheel.py index 06a4a5d4..6d210a64 100644 --- a/setuptools/command/editable_wheel.py +++ b/setuptools/command/editable_wheel.py @@ -11,11 +11,12 @@ Create a wheel that, when installed, will make the source package 'editable' """ import os +import re import shutil import sys from pathlib import Path from tempfile import TemporaryDirectory -from typing import Dict, Iterable, Iterator, List, Union +from typing import Dict, Iterable, Iterator, List, Mapping, Set, Union from setuptools import Command, namespaces from setuptools.discovery import find_package_path @@ -131,9 +132,7 @@ class editable_wheel(Command): # >>> 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) + return _TopLevelFinder(dist, name) class _StaticPth: @@ -148,11 +147,38 @@ class _StaticPth: pth.write_text(f"{entries}\n", encoding="utf-8") +class _TopLevelFinder: + def __init__(self, dist: Distribution, name: str): + self.dist = dist + self.name = name + + def __call__(self, unpacked_wheel_dir: Path): + src_root = self.dist.src_root or os.curdir + package_dir = self.dist.package_dir or {} + packages = _find_packages(self.dist) + pkg_roots = _find_pkg_roots(packages, package_dir, src_root) + namespaces_ = set(_find_mapped_namespaces(pkg_roots)) + + finder = _make_identifier(f"__editable__.{self.name}.finder") + content = _finder_template(pkg_roots, namespaces_) + Path(unpacked_wheel_dir, f"{finder}.py").write_text(content, encoding="utf-8") + + pth = f"__editable__.{self.name}.pth" + content = f"import {finder}; {finder}.install()" + Path(unpacked_wheel_dir, pth).write_text(content, 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'], {"": "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"}, ".") @@ -172,13 +198,25 @@ def _simple_layout( pkg: find_package_path(pkg, package_dir, project_dir) for pkg in packages } - parent = os.path.commonpath(list(layout.values())) + parent = os.path.commonpath([_parent_path(k, v) for k, v in layout.items()]) return all( _normalize_path(Path(parent, *key.split('.'))) == _normalize_path(value) for key, value in layout.items() ) +def _parent_path(pkg, pkg_path): + """Infer the parent path for a package if possible. When the 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 []) @@ -195,6 +233,76 @@ def _find_packages(dist: Distribution) -> Iterator[str]: yield package +def _find_pkg_roots( + packages: Iterable[str], + package_dir: Mapping[str, str], + src_root: _Path, +) -> 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: _Path) -> 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_mapped_namespaces(pkg_roots: Dict[str, str]) -> Iterator[str]: + """By carefully designing ``package_dir``, it is possible to implement + PEP 420 compatible namespaces without creating extra folders. + This function will try to find this kind 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 = find_package_path(partial_name, pkg_roots, "") + if not Path(path, "__init__.py").exists(): + yield partial_name + + +def _remove_nested(pkg_roots: Dict[str, str]) -> Dict[str, str]: + output = dict(pkg_roots.copy()) + + for pkg, path in reversed(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: + """ + >>> _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 + """ + norm_pkg_path = _normalize_path(pkg_path) + rest = pkg.replace(parent, "").strip(".").split(".") + return ( + pkg.startswith(parent) + and norm_pkg_path == _normalize_path(Path(parent_path, *rest)) + ) + + def _normalize_path(filename: _Path) -> str: """Normalize a file/dir name for comparison purposes""" # See pkg_resources.normalize_path @@ -208,6 +316,18 @@ def _empty_dir(dir_: Path) -> Path: return dir_ +def _make_identifier(name: str) -> str: + """Make a string safe to be used as Python identifier. + >>> _make_identifier("12abc") + '_12abc' + >>> _make_identifier("__editable__.myns.pkg-78.9.3_local") + '__editable___myns_pkg_78_9_3_local' + """ + safe = re.sub(r'\W|^(?=\d)', '_', name) + assert safe.isidentifier() + return safe + + class _NamespaceInstaller(namespaces.Installer): def __init__(self, distribution, installation_dir, editable_name, src_root): self.distribution = distribution @@ -225,19 +345,19 @@ class _NamespaceInstaller(namespaces.Installer): return repr(str(self.src_root)) -_FINDER_TEMPLATE = """ +_FINDER_TEMPLATE = """\ +import sys +from importlib.machinery import all_suffixes as module_suffixes +from importlib.machinery import ModuleSpec +from importlib.util import spec_from_file_location +from itertools import chain +from pathlib import Path + class __EditableFinder: MAPPING = {mapping!r} NAMESPACES = {namespaces!r} @classmethod - def install(cls): - import sys - - if not any(finder == cls for finder in sys.meta_path): - sys.meta_path.append(cls) - - @classmethod def find_spec(cls, fullname, path, target=None): if fullname in cls.NAMESPACES: return cls._namespace_spec(fullname) @@ -251,18 +371,12 @@ class __EditableFinder: @classmethod def _namespace_spec(cls, name): # Since `cls` is appended to the path, this will only trigger - # when no other package is installed in the same namespace - from importlib.machinery import ModuleSpec - - # PEP 451 mentions setting loader to None for namespaces: + # when no other package is installed in the same namespace. return ModuleSpec(name, None, is_package=True) + # ^-- PEP 451 mentions setting loader to None for namespaces. @classmethod def _find_spec(cls, fullname, parent, parent_path): - from importlib.machinery import all_suffixes as module_suffixes - from importlib.util import spec_from_file_location - from itertools import chain - rest = fullname.replace(parent, "").strip(".").split(".") candidate_path = Path(parent_path, *rest) @@ -273,8 +387,18 @@ class __EditableFinder: spec = spec_from_file_location(fullname, candidate) return spec + if candidate_path.exists(): + return cls._namespace_spec(fullname) + return None -__EditableFinder.install() +def install(): + if not any(finder == __EditableFinder for finder in sys.meta_path): + sys.meta_path.append(__EditableFinder) """ + + +def _finder_template(mapping: Mapping[str, str], namespaces: Set[str]): + mapping = dict(sorted(mapping.items(), key=lambda p: p[0])) + return _FINDER_TEMPLATE.format(mapping=mapping, namespaces=namespaces) |
