Source code
Revision control
Copy as Markdown
Other Tools
from __future__ import annotations
import collections
import contextlib
import pathlib
import sys
import tempfile
from dataclasses import dataclass
from importlib import metadata as importlib_metadata
from typing import Any, Iterator, Protocol, TypeVar, overload
import build
import build.env
import pyproject_hooks
from pip._internal.req import InstallRequirement
from pip._internal.req.constructors import install_req_from_line, parse_req_from_line
PYPROJECT_TOML = "pyproject.toml"
_T = TypeVar("_T")
if sys.version_info >= (3, 10):
from importlib.metadata import PackageMetadata
else:
class PackageMetadata(Protocol):
@overload
def get_all(self, name: str, failobj: None = None) -> list[Any] | None: ...
@overload
def get_all(self, name: str, failobj: _T) -> list[Any] | _T: ...
@dataclass
class ProjectMetadata:
extras: tuple[str, ...]
requirements: tuple[InstallRequirement, ...]
build_requirements: tuple[InstallRequirement, ...]
def build_project_metadata(
src_file: pathlib.Path,
build_targets: tuple[str, ...],
*,
isolated: bool,
quiet: bool,
) -> ProjectMetadata:
"""
Return the metadata for a project.
Uses the ``prepare_metadata_for_build_wheel`` hook for the wheel metadata
if available, otherwise ``build_wheel``.
Uses the ``prepare_metadata_for_build_{target}`` hook for each ``build_targets``
if available.
:param src_file: Project source file
:param build_targets: A tuple of build targets to get the dependencies
of (``sdist`` or ``wheel`` or ``editable``).
:param isolated: Whether to run invoke the backend in the current
environment or to create an isolated one and invoke it
there.
:param quiet: Whether to suppress the output of subprocesses.
"""
src_dir = src_file.parent
with _create_project_builder(src_dir, isolated=isolated, quiet=quiet) as builder:
metadata = _build_project_wheel_metadata(builder)
extras = tuple(metadata.get_all("Provides-Extra") or ())
requirements = tuple(
_prepare_requirements(metadata=metadata, src_file=src_file)
)
build_requirements = tuple(
_prepare_build_requirements(
builder=builder,
src_file=src_file,
build_targets=build_targets,
package_name=_get_name(metadata),
)
)
return ProjectMetadata(
extras=extras,
requirements=requirements,
build_requirements=build_requirements,
)
@contextlib.contextmanager
def _create_project_builder(
src_dir: pathlib.Path, *, isolated: bool, quiet: bool
) -> Iterator[build.ProjectBuilder]:
if quiet:
runner = pyproject_hooks.quiet_subprocess_runner
else:
runner = pyproject_hooks.default_subprocess_runner
if not isolated:
yield build.ProjectBuilder(src_dir, runner=runner)
return
with build.env.DefaultIsolatedEnv() as env:
builder = build.ProjectBuilder.from_isolated_env(env, src_dir, runner)
env.install(builder.build_system_requires)
env.install(builder.get_requires_for_build("wheel"))
yield builder
def _build_project_wheel_metadata(
builder: build.ProjectBuilder,
) -> PackageMetadata:
with tempfile.TemporaryDirectory() as tmpdir:
path = pathlib.Path(builder.metadata_path(tmpdir))
return importlib_metadata.PathDistribution(path).metadata
def _get_name(metadata: PackageMetadata) -> str:
retval = metadata.get_all("Name")[0] # type: ignore[index]
assert isinstance(retval, str)
return retval
def _prepare_requirements(
metadata: PackageMetadata, src_file: pathlib.Path
) -> Iterator[InstallRequirement]:
package_name = _get_name(metadata)
comes_from = f"{package_name} ({src_file})"
package_dir = src_file.parent
for req in metadata.get_all("Requires-Dist") or []:
parts = parse_req_from_line(req, comes_from)
if parts.requirement.name == package_name:
# Replace package name with package directory in the requirement
# string so that pip can find the package as self-referential.
# Note the string can contain extras, so we need to replace only
# the package name, not the whole string.
replaced_package_name = req.replace(package_name, str(package_dir), 1)
parts = parse_req_from_line(replaced_package_name, comes_from)
yield InstallRequirement(
parts.requirement,
comes_from,
link=parts.link,
markers=parts.markers,
extras=parts.extras,
)
def _prepare_build_requirements(
builder: build.ProjectBuilder,
src_file: pathlib.Path,
build_targets: tuple[str, ...],
package_name: str,
) -> Iterator[InstallRequirement]:
result = collections.defaultdict(set)
# Build requirements will only be present if a pyproject.toml file exists,
# but if there is also a setup.py file then only that will be explicitly
# processed due to the order of `DEFAULT_REQUIREMENTS_FILES`.
src_file = src_file.parent / PYPROJECT_TOML
for req in builder.build_system_requires:
result[req].add(f"{package_name} ({src_file}::build-system.requires)")
for build_target in build_targets:
for req in builder.get_requires_for_build(build_target):
result[req].add(
f"{package_name} ({src_file}::build-system.backend::{build_target})"
)
for req, comes_from_sources in result.items():
for comes_from in comes_from_sources:
yield install_req_from_line(req, comes_from=comes_from)