1from __future__ import annotations
2
3import os
4from collections import namedtuple
5from typing import Any
6
7from pip._vendor.packaging.requirements import InvalidRequirement
8
9from pip._internal.exceptions import (
10 InstallationError,
11 InvalidPyProjectBuildRequires,
12 MissingPyProjectBuildRequires,
13)
14from pip._internal.utils.compat import tomllib
15from pip._internal.utils.packaging import get_requirement
16
17
18def _is_list_of_str(obj: Any) -> bool:
19 return isinstance(obj, list) and all(isinstance(item, str) for item in obj)
20
21
22def make_pyproject_path(unpacked_source_directory: str) -> str:
23 return os.path.join(unpacked_source_directory, "pyproject.toml")
24
25
26BuildSystemDetails = namedtuple(
27 "BuildSystemDetails", ["requires", "backend", "check", "backend_path"]
28)
29
30
31def load_pyproject_toml(
32 pyproject_toml: str, setup_py: str, req_name: str
33) -> BuildSystemDetails:
34 """Load the pyproject.toml file.
35
36 Parameters:
37 pyproject_toml - Location of the project's pyproject.toml file
38 setup_py - Location of the project's setup.py file
39 req_name - The name of the requirement we're processing (for
40 error reporting)
41
42 Returns:
43 None if we should use the legacy code path, otherwise a tuple
44 (
45 requirements from pyproject.toml,
46 name of PEP 517 backend,
47 requirements we should check are installed after setting
48 up the build environment
49 directory paths to import the backend from (backend-path),
50 relative to the project root.
51 )
52 """
53 has_pyproject = os.path.isfile(pyproject_toml)
54 has_setup = os.path.isfile(setup_py)
55
56 if not has_pyproject and not has_setup:
57 raise InstallationError(
58 f"{req_name} does not appear to be a Python project: "
59 f"neither 'setup.py' nor 'pyproject.toml' found."
60 )
61
62 if has_pyproject:
63 with open(pyproject_toml, encoding="utf-8") as f:
64 pp_toml = tomllib.loads(f.read())
65 build_system = pp_toml.get("build-system")
66 else:
67 build_system = None
68
69 if build_system is None:
70 # In the absence of any explicit backend specification, we
71 # assume the setuptools backend that most closely emulates the
72 # traditional direct setup.py execution, and require wheel and
73 # a version of setuptools that supports that backend.
74
75 build_system = {
76 "requires": ["setuptools>=40.8.0"],
77 "build-backend": "setuptools.build_meta:__legacy__",
78 }
79
80 # Ensure that the build-system section in pyproject.toml conforms
81 # to PEP 518.
82
83 # Specifying the build-system table but not the requires key is invalid
84 if "requires" not in build_system:
85 raise MissingPyProjectBuildRequires(package=req_name)
86
87 # Error out if requires is not a list of strings
88 requires = build_system["requires"]
89 if not _is_list_of_str(requires):
90 raise InvalidPyProjectBuildRequires(
91 package=req_name,
92 reason="It is not a list of strings.",
93 )
94
95 # Each requirement must be valid as per PEP 508
96 for requirement in requires:
97 try:
98 get_requirement(requirement)
99 except InvalidRequirement as error:
100 raise InvalidPyProjectBuildRequires(
101 package=req_name,
102 reason=f"It contains an invalid requirement: {requirement!r}",
103 ) from error
104
105 backend = build_system.get("build-backend")
106 backend_path = build_system.get("backend-path", [])
107 check: list[str] = []
108 if backend is None:
109 # If the user didn't specify a backend, we assume they want to use
110 # the setuptools backend. But we can't be sure they have included
111 # a version of setuptools which supplies the backend. So we
112 # make a note to check that this requirement is present once
113 # we have set up the environment.
114 # This is quite a lot of work to check for a very specific case. But
115 # the problem is, that case is potentially quite common - projects that
116 # adopted PEP 518 early for the ability to specify requirements to
117 # execute setup.py, but never considered needing to mention the build
118 # tools themselves. The original PEP 518 code had a similar check (but
119 # implemented in a different way).
120 backend = "setuptools.build_meta:__legacy__"
121 check = ["setuptools>=40.8.0"]
122
123 return BuildSystemDetails(requires, backend, check, backend_path)