Merge pull request #11059 from pfmoore/config_settings

Add a UI to set config settings for PEP 517 backends
This commit is contained in:
Paul Moore 2022-04-29 12:58:32 +01:00 committed by GitHub
commit cdeb8f9e63
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 336 additions and 1 deletions

View File

@ -106,6 +106,16 @@ This is considered a stopgap solution until setuptools adds support for
regular {ref}`deprecation policy <Deprecation Policy>`.
```
### Backend Configuration
Build backends have the ability to accept configuration settings, which can
change the way the build is handled. These settings take the form of a
series of `key=value` pairs. The user can supply configuration settings
using the `--config-settings` command line option (which can be supplied
multiple times, in order to specify multiple settings).
The supplied configuration settings are passed to every backend hook call.
## Build output
It is the responsibility of the build backend to ensure that the output is

View File

@ -111,6 +111,7 @@ The options which can be applied to individual requirements are:
- {ref}`--install-option <install_--install-option>`
- {ref}`--global-option <install_--global-option>`
- {ref}`--config-settings <install_--config-settings>`
- `--hash` (for {ref}`Hash-checking mode`)
## Referring to other requirements files

1
news/11059.feature.rst Normal file
View File

@ -0,0 +1 @@
Add a user interface for supplying config settings to build backends.

View File

@ -801,6 +801,33 @@ no_use_pep517: Any = partial(
help=SUPPRESS_HELP,
)
def _handle_config_settings(
option: Option, opt_str: str, value: str, parser: OptionParser
) -> None:
key, sep, val = value.partition("=")
if sep != "=":
parser.error(f"Arguments to {opt_str} must be of the form KEY=VAL") # noqa
dest = getattr(parser.values, option.dest)
if dest is None:
dest = {}
setattr(parser.values, option.dest, dest)
dest[key] = val
config_settings: Callable[..., Option] = partial(
Option,
"--config-settings",
dest="config_settings",
type=str,
action="callback",
callback=_handle_config_settings,
metavar="settings",
help="Configuration settings to be passed to the PEP 517 build backend. "
"Settings take the form KEY=VALUE. Use multiple --config-settings options "
"to pass multiple keys to the backend.",
)
install_options: Callable[..., Option] = partial(
Option,
"--install-option",

View File

@ -325,6 +325,7 @@ class RequirementCommand(IndexGroupCommand):
install_req_from_req_string,
isolated=options.isolated_mode,
use_pep517=use_pep517,
config_settings=getattr(options, "config_settings", None),
)
suppress_build_failures = cls.determine_build_failure_suppression(options)
resolver_variant = cls.determine_resolver_variant(options)
@ -397,6 +398,7 @@ class RequirementCommand(IndexGroupCommand):
isolated=options.isolated_mode,
use_pep517=options.use_pep517,
user_supplied=True,
config_settings=getattr(options, "config_settings", None),
)
requirements.append(req_to_add)
@ -406,6 +408,7 @@ class RequirementCommand(IndexGroupCommand):
user_supplied=True,
isolated=options.isolated_mode,
use_pep517=options.use_pep517,
config_settings=getattr(options, "config_settings", None),
)
requirements.append(req_to_add)

View File

@ -190,6 +190,7 @@ class InstallCommand(RequirementCommand):
self.cmd_opts.add_option(cmdoptions.use_pep517())
self.cmd_opts.add_option(cmdoptions.no_use_pep517())
self.cmd_opts.add_option(cmdoptions.config_settings())
self.cmd_opts.add_option(cmdoptions.install_options())
self.cmd_opts.add_option(cmdoptions.global_options())

View File

@ -73,6 +73,7 @@ class WheelCommand(RequirementCommand):
help="Don't verify if built wheel is valid.",
)
self.cmd_opts.add_option(cmdoptions.config_settings())
self.cmd_opts.add_option(cmdoptions.build_options())
self.cmd_opts.add_option(cmdoptions.global_options())

View File

@ -207,6 +207,7 @@ def install_req_from_editable(
constraint: bool = False,
user_supplied: bool = False,
permit_editable_wheels: bool = False,
config_settings: Optional[Dict[str, str]] = None,
) -> InstallRequirement:
parts = parse_req_from_editable(editable_req)
@ -224,6 +225,7 @@ def install_req_from_editable(
install_options=options.get("install_options", []) if options else [],
global_options=options.get("global_options", []) if options else [],
hash_options=options.get("hashes", {}) if options else {},
config_settings=config_settings,
extras=parts.extras,
)
@ -380,6 +382,7 @@ def install_req_from_line(
constraint: bool = False,
line_source: Optional[str] = None,
user_supplied: bool = False,
config_settings: Optional[Dict[str, str]] = None,
) -> InstallRequirement:
"""Creates an InstallRequirement from a name, which might be a
requirement, directory containing 'setup.py', filename, or URL.
@ -399,6 +402,7 @@ def install_req_from_line(
install_options=options.get("install_options", []) if options else [],
global_options=options.get("global_options", []) if options else [],
hash_options=options.get("hashes", {}) if options else {},
config_settings=config_settings,
constraint=constraint,
extras=parts.extras,
user_supplied=user_supplied,
@ -411,6 +415,7 @@ def install_req_from_req_string(
isolated: bool = False,
use_pep517: Optional[bool] = None,
user_supplied: bool = False,
config_settings: Optional[Dict[str, str]] = None,
) -> InstallRequirement:
try:
req = get_requirement(req_string)
@ -440,6 +445,7 @@ def install_req_from_req_string(
isolated=isolated,
use_pep517=use_pep517,
user_supplied=user_supplied,
config_settings=config_settings,
)
@ -448,6 +454,7 @@ def install_req_from_parsed_requirement(
isolated: bool = False,
use_pep517: Optional[bool] = None,
user_supplied: bool = False,
config_settings: Optional[Dict[str, str]] = None,
) -> InstallRequirement:
if parsed_req.is_editable:
req = install_req_from_editable(
@ -457,6 +464,7 @@ def install_req_from_parsed_requirement(
constraint=parsed_req.constraint,
isolated=isolated,
user_supplied=user_supplied,
config_settings=config_settings,
)
else:
@ -469,6 +477,7 @@ def install_req_from_parsed_requirement(
constraint=parsed_req.constraint,
line_source=parsed_req.line_source,
user_supplied=user_supplied,
config_settings=config_settings,
)
return req
@ -487,4 +496,5 @@ def install_req_from_link_and_ireq(
install_options=ireq.install_options,
global_options=ireq.global_options,
hash_options=ireq.hash_options,
config_settings=ireq.config_settings,
)

View File

@ -46,6 +46,7 @@ from pip._internal.utils.direct_url_helpers import (
)
from pip._internal.utils.hashes import Hashes
from pip._internal.utils.misc import (
ConfiguredPep517HookCaller,
ask_path_exists,
backup_dir,
display_path,
@ -80,6 +81,7 @@ class InstallRequirement:
install_options: Optional[List[str]] = None,
global_options: Optional[List[str]] = None,
hash_options: Optional[Dict[str, List[str]]] = None,
config_settings: Optional[Dict[str, str]] = None,
constraint: bool = False,
extras: Collection[str] = (),
user_supplied: bool = False,
@ -138,6 +140,7 @@ class InstallRequirement:
self.install_options = install_options if install_options else []
self.global_options = global_options if global_options else []
self.hash_options = hash_options if hash_options else {}
self.config_settings = config_settings
# Set to True after successful preparation of this requirement
self.prepared = False
# User supplied requirement are explicitly requested for installation
@ -469,7 +472,8 @@ class InstallRequirement:
requires, backend, check, backend_path = pyproject_toml_data
self.requirements_to_check = check
self.pyproject_requires = requires
self.pep517_backend = Pep517HookCaller(
self.pep517_backend = ConfiguredPep517HookCaller(
self,
self.unpacked_source_directory,
backend,
backend_path=backend_path,

View File

@ -69,6 +69,7 @@ def make_install_req_from_link(
global_options=template.global_options,
hashes=template.hash_options,
),
config_settings=template.config_settings,
)
ireq.original_link = template.original_link
ireq.link = link
@ -92,6 +93,7 @@ def make_install_req_from_editable(
global_options=template.global_options,
hashes=template.hash_options,
),
config_settings=template.config_settings,
)
@ -116,6 +118,7 @@ def _make_install_req_from_dist(
global_options=template.global_options,
hashes=template.hash_options,
),
config_settings=template.config_settings,
)
ireq.satisfied_by = dist
return ireq

View File

@ -21,6 +21,7 @@ from typing import (
BinaryIO,
Callable,
ContextManager,
Dict,
Generator,
Iterable,
Iterator,
@ -33,6 +34,7 @@ from typing import (
cast,
)
from pip._vendor.pep517 import Pep517HookCaller
from pip._vendor.tenacity import retry, stop_after_delay, wait_fixed
from pip import __version__
@ -55,6 +57,7 @@ __all__ = [
"captured_stdout",
"ensure_dir",
"remove_auth_from_url",
"ConfiguredPep517HookCaller",
]
@ -630,3 +633,91 @@ def partition(
"""
t1, t2 = tee(iterable)
return filterfalse(pred, t1), filter(pred, t2)
class ConfiguredPep517HookCaller(Pep517HookCaller):
def __init__(
self,
config_holder: Any,
source_dir: str,
build_backend: str,
backend_path: Optional[str] = None,
runner: Optional[Callable[..., None]] = None,
python_executable: Optional[str] = None,
):
super().__init__(
source_dir, build_backend, backend_path, runner, python_executable
)
self.config_holder = config_holder
def build_wheel(
self,
wheel_directory: str,
config_settings: Optional[Dict[str, str]] = None,
metadata_directory: Optional[str] = None,
) -> str:
cs = self.config_holder.config_settings
return super().build_wheel(
wheel_directory, config_settings=cs, metadata_directory=metadata_directory
)
def build_sdist(
self, sdist_directory: str, config_settings: Optional[Dict[str, str]] = None
) -> str:
cs = self.config_holder.config_settings
return super().build_sdist(sdist_directory, config_settings=cs)
def build_editable(
self,
wheel_directory: str,
config_settings: Optional[Dict[str, str]] = None,
metadata_directory: Optional[str] = None,
) -> str:
cs = self.config_holder.config_settings
return super().build_editable(
wheel_directory, config_settings=cs, metadata_directory=metadata_directory
)
def get_requires_for_build_wheel(
self, config_settings: Optional[Dict[str, str]] = None
) -> List[str]:
cs = self.config_holder.config_settings
return super().get_requires_for_build_wheel(config_settings=cs)
def get_requires_for_build_sdist(
self, config_settings: Optional[Dict[str, str]] = None
) -> List[str]:
cs = self.config_holder.config_settings
return super().get_requires_for_build_sdist(config_settings=cs)
def get_requires_for_build_editable(
self, config_settings: Optional[Dict[str, str]] = None
) -> List[str]:
cs = self.config_holder.config_settings
return super().get_requires_for_build_editable(config_settings=cs)
def prepare_metadata_for_build_wheel(
self,
metadata_directory: str,
config_settings: Optional[Dict[str, str]] = None,
_allow_fallback: bool = True,
) -> str:
cs = self.config_holder.config_settings
return super().prepare_metadata_for_build_wheel(
metadata_directory=metadata_directory,
config_settings=cs,
_allow_fallback=_allow_fallback,
)
def prepare_metadata_for_build_editable(
self,
metadata_directory: str,
config_settings: Optional[Dict[str, str]] = None,
_allow_fallback: bool = True,
) -> str:
cs = self.config_holder.config_settings
return super().prepare_metadata_for_build_editable(
metadata_directory=metadata_directory,
config_settings=cs,
_allow_fallback=_allow_fallback,
)

View File

@ -0,0 +1,139 @@
import json
from typing import Tuple
from zipfile import ZipFile
from tests.lib import PipTestEnvironment
from tests.lib.path import Path
PYPROJECT_TOML = """\
[build-system]
requires = []
build-backend = "dummy_backend:main"
backend-path = ["backend"]
"""
BACKEND_SRC = '''
import csv
import json
import os.path
from zipfile import ZipFile
import hashlib
import base64
import io
WHEEL = """\
Wheel-Version: 1.0
Generator: dummy_backend 1.0
Root-Is-Purelib: true
Tag: py3-none-any
"""
METADATA = """\
Metadata-Version: 2.1
Name: {project}
Version: {version}
Summary: A dummy package
Author: None
Author-email: none@example.org
License: MIT
"""
def make_wheel(z, project, version, files):
record = []
def add_file(name, data):
data = data.encode("utf-8")
z.writestr(name, data)
digest = hashlib.sha256(data).digest()
hash = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ASCII")
record.append((name, f"sha256={hash}", len(data)))
distinfo = f"{project}-{version}.dist-info"
add_file(f"{distinfo}/WHEEL", WHEEL)
add_file(f"{distinfo}/METADATA", METADATA.format(project=project, version=version))
for name, data in files:
add_file(name, data)
record_name = f"{distinfo}/RECORD"
record.append((record_name, "", ""))
b = io.BytesIO()
rec = io.TextIOWrapper(b, newline="", encoding="utf-8")
w = csv.writer(rec)
w.writerows(record)
z.writestr(record_name, b.getvalue())
rec.close()
class Backend:
def build_wheel(
self,
wheel_directory,
config_settings=None,
metadata_directory=None
):
if config_settings is None:
config_settings = {}
w = os.path.join(wheel_directory, "foo-1.0-py3-none-any.whl")
with open(w, "wb") as f:
with ZipFile(f, "w") as z:
make_wheel(
z, "foo", "1.0",
[("config.json", json.dumps(config_settings))]
)
return "foo-1.0-py3-none-any.whl"
build_editable = build_wheel
main = Backend()
'''
def make_project(path: Path) -> Tuple[str, str, Path]:
name = "foo"
version = "1.0"
project_dir = path / name
backend = project_dir / "backend"
backend.mkdir(parents=True)
(project_dir / "pyproject.toml").write_text(PYPROJECT_TOML)
(backend / "dummy_backend.py").write_text(BACKEND_SRC)
return name, version, project_dir
def test_backend_sees_config(script: PipTestEnvironment) -> None:
name, version, project_dir = make_project(script.scratch_path)
script.pip(
"wheel",
"--config-settings",
"FOO=Hello",
project_dir,
)
wheel_file_name = f"{name}-{version}-py3-none-any.whl"
wheel_file_path = script.cwd / wheel_file_name
with open(wheel_file_path, "rb") as f:
with ZipFile(f) as z:
output = z.read("config.json")
assert json.loads(output) == {"FOO": "Hello"}
def test_install_sees_config(script: PipTestEnvironment) -> None:
_, _, project_dir = make_project(script.scratch_path)
script.pip(
"install",
"--config-settings",
"FOO=Hello",
project_dir,
)
config = script.site_packages_path / "config.json"
with open(config, "rb") as f:
assert json.load(f) == {"FOO": "Hello"}
def test_install_editable_sees_config(script: PipTestEnvironment) -> None:
_, _, project_dir = make_project(script.scratch_path)
script.pip(
"install",
"--config-settings",
"FOO=Hello",
"--editable",
project_dir,
)
config = script.site_packages_path / "config.json"
with open(config, "rb") as f:
assert json.load(f) == {"FOO": "Hello"}

View File

@ -0,0 +1,44 @@
import pytest
from pip._internal.commands import create_command
@pytest.mark.parametrize(
("command", "expected"),
[
("install", True),
("wheel", True),
("freeze", False),
],
)
def test_supports_config(command: str, expected: bool) -> None:
c = create_command(command)
options, _ = c.parse_args([])
assert hasattr(options, "config_settings") == expected
def test_set_config_value_true() -> None:
i = create_command("install")
# Invalid argument exits with an error
with pytest.raises(SystemExit):
options, _ = i.parse_args(["xxx", "--config-settings", "x"])
def test_set_config_value() -> None:
i = create_command("install")
options, _ = i.parse_args(["xxx", "--config-settings", "x=hello"])
assert options.config_settings == {"x": "hello"}
def test_set_config_empty_value() -> None:
i = create_command("install")
options, _ = i.parse_args(["xxx", "--config-settings", "x="])
assert options.config_settings == {"x": ""}
def test_replace_config_value() -> None:
i = create_command("install")
options, _ = i.parse_args(
["xxx", "--config-settings", "x=hello", "--config-settings", "x=world"]
)
assert options.config_settings == {"x": "world"}