|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import collections |
| 4 | +import contextlib |
| 5 | +import pathlib |
| 6 | +import sys |
| 7 | +import tempfile |
| 8 | +from dataclasses import dataclass |
| 9 | +from importlib import metadata as importlib_metadata |
| 10 | +from typing import Any, Iterator, Protocol, TypeVar, overload |
| 11 | + |
| 12 | +import build |
| 13 | +import build.env |
| 14 | +import pyproject_hooks |
| 15 | +from pip._internal.req import InstallRequirement |
| 16 | +from pip._internal.req.constructors import install_req_from_line, parse_req_from_line |
| 17 | + |
| 18 | +PYPROJECT_TOML = "pyproject.toml" |
| 19 | + |
| 20 | +_T = TypeVar("_T") |
| 21 | + |
| 22 | + |
| 23 | +if sys.version_info >= (3, 10): |
| 24 | + from importlib.metadata import PackageMetadata |
| 25 | +else: |
| 26 | + |
| 27 | + class PackageMetadata(Protocol): |
| 28 | + @overload |
| 29 | + def get_all(self, name: str, failobj: None = None) -> list[Any] | None: |
| 30 | + ... |
| 31 | + |
| 32 | + @overload |
| 33 | + def get_all(self, name: str, failobj: _T) -> list[Any] | _T: |
| 34 | + ... |
| 35 | + |
| 36 | + |
| 37 | +@dataclass |
| 38 | +class ProjectMetadata: |
| 39 | + extras: tuple[str, ...] |
| 40 | + requirements: tuple[InstallRequirement, ...] |
| 41 | + build_requirements: tuple[InstallRequirement, ...] |
| 42 | + |
| 43 | + |
| 44 | +def build_project_metadata( |
| 45 | + src_file: pathlib.Path, |
| 46 | + build_targets: tuple[str, ...], |
| 47 | + *, |
| 48 | + isolated: bool, |
| 49 | + quiet: bool, |
| 50 | +) -> ProjectMetadata: |
| 51 | + """ |
| 52 | + Return the metadata for a project. |
| 53 | +
|
| 54 | + Uses the ``prepare_metadata_for_build_wheel`` hook for the wheel metadata |
| 55 | + if available, otherwise ``build_wheel``. |
| 56 | +
|
| 57 | + Uses the ``prepare_metadata_for_build_{target}`` hook for each ``build_targets`` |
| 58 | + if available. |
| 59 | +
|
| 60 | + :param src_file: Project source file |
| 61 | + :param build_targets: A tuple of build targets to get the dependencies |
| 62 | + of (``sdist`` or ``wheel`` or ``editable``). |
| 63 | + :param isolated: Whether to run invoke the backend in the current |
| 64 | + environment or to create an isolated one and invoke it |
| 65 | + there. |
| 66 | + :param quiet: Whether to suppress the output of subprocesses. |
| 67 | + """ |
| 68 | + |
| 69 | + src_dir = src_file.parent |
| 70 | + with _create_project_builder(src_dir, isolated=isolated, quiet=quiet) as builder: |
| 71 | + metadata = _build_project_wheel_metadata(builder) |
| 72 | + extras = tuple(metadata.get_all("Provides-Extra") or ()) |
| 73 | + requirements = tuple( |
| 74 | + _prepare_requirements(metadata=metadata, src_file=src_file) |
| 75 | + ) |
| 76 | + build_requirements = tuple( |
| 77 | + _prepare_build_requirements( |
| 78 | + builder=builder, |
| 79 | + src_file=src_file, |
| 80 | + build_targets=build_targets, |
| 81 | + package_name=_get_name(metadata), |
| 82 | + ) |
| 83 | + ) |
| 84 | + return ProjectMetadata( |
| 85 | + extras=extras, |
| 86 | + requirements=requirements, |
| 87 | + build_requirements=build_requirements, |
| 88 | + ) |
| 89 | + |
| 90 | + |
| 91 | +@contextlib.contextmanager |
| 92 | +def _create_project_builder( |
| 93 | + src_dir: pathlib.Path, *, isolated: bool, quiet: bool |
| 94 | +) -> Iterator[build.ProjectBuilder]: |
| 95 | + if quiet: |
| 96 | + runner = pyproject_hooks.quiet_subprocess_runner |
| 97 | + else: |
| 98 | + runner = pyproject_hooks.default_subprocess_runner |
| 99 | + |
| 100 | + if not isolated: |
| 101 | + yield build.ProjectBuilder(src_dir, runner=runner) |
| 102 | + return |
| 103 | + |
| 104 | + with build.env.DefaultIsolatedEnv() as env: |
| 105 | + builder = build.ProjectBuilder.from_isolated_env(env, src_dir, runner) |
| 106 | + env.install(builder.build_system_requires) |
| 107 | + env.install(builder.get_requires_for_build("wheel")) |
| 108 | + yield builder |
| 109 | + |
| 110 | + |
| 111 | +def _build_project_wheel_metadata( |
| 112 | + builder: build.ProjectBuilder, |
| 113 | +) -> PackageMetadata: |
| 114 | + with tempfile.TemporaryDirectory() as tmpdir: |
| 115 | + path = pathlib.Path(builder.metadata_path(tmpdir)) |
| 116 | + return importlib_metadata.PathDistribution(path).metadata |
| 117 | + |
| 118 | + |
| 119 | +def _get_name(metadata: PackageMetadata) -> str: |
| 120 | + retval = metadata.get_all("Name")[0] # type: ignore[index] |
| 121 | + assert isinstance(retval, str) |
| 122 | + return retval |
| 123 | + |
| 124 | + |
| 125 | +def _prepare_requirements( |
| 126 | + metadata: PackageMetadata, src_file: pathlib.Path |
| 127 | +) -> Iterator[InstallRequirement]: |
| 128 | + package_name = _get_name(metadata) |
| 129 | + comes_from = f"{package_name} ({src_file})" |
| 130 | + package_dir = src_file.parent |
| 131 | + |
| 132 | + for req in metadata.get_all("Requires-Dist") or []: |
| 133 | + parts = parse_req_from_line(req, comes_from) |
| 134 | + if parts.requirement.name == package_name: |
| 135 | + # Replace package name with package directory in the requirement |
| 136 | + # string so that pip can find the package as self-referential. |
| 137 | + # Note the string can contain extras, so we need to replace only |
| 138 | + # the package name, not the whole string. |
| 139 | + replaced_package_name = req.replace(package_name, package_dir, 1) |
| 140 | + parts = parse_req_from_line(replaced_package_name, comes_from) |
| 141 | + |
| 142 | + yield InstallRequirement( |
| 143 | + parts.requirement, |
| 144 | + comes_from, |
| 145 | + link=parts.link, |
| 146 | + markers=parts.markers, |
| 147 | + extras=parts.extras, |
| 148 | + ) |
| 149 | + |
| 150 | + |
| 151 | +def _prepare_build_requirements( |
| 152 | + builder: build.ProjectBuilder, |
| 153 | + src_file: pathlib.Path, |
| 154 | + build_targets: tuple[str, ...], |
| 155 | + package_name: str, |
| 156 | +) -> Iterator[InstallRequirement]: |
| 157 | + result = collections.defaultdict(set) |
| 158 | + |
| 159 | + # Build requirements will only be present if a pyproject.toml file exists, |
| 160 | + # but if there is also a setup.py file then only that will be explicitly |
| 161 | + # processed due to the order of `DEFAULT_REQUIREMENTS_FILES`. |
| 162 | + src_file = src_file.parent / PYPROJECT_TOML |
| 163 | + |
| 164 | + for req in builder.build_system_requires: |
| 165 | + result[req].add(f"{package_name} ({src_file}::build-system.requires)") |
| 166 | + for build_target in build_targets: |
| 167 | + for req in builder.get_requires_for_build(build_target): |
| 168 | + result[req].add( |
| 169 | + f"{package_name} ({src_file}::build-system.backend::{build_target})" |
| 170 | + ) |
| 171 | + |
| 172 | + for req, comes_from_sources in result.items(): |
| 173 | + for comes_from in comes_from_sources: |
| 174 | + yield install_req_from_line(req, comes_from=comes_from) |
0 commit comments