Repository: pypa/pip

Issue:
Per requirements `--config-settings`
### What's the problem this feature will solve?

Currently we can specify `--install-options` and `--global-options` per requirement in requirement files.

These options are on the way to deprecation as they trigger the legacy `setup.py install` code path.

Towards https://github.com/pypa/pip/issues/8102.

### Describe the solution you'd like

Allow `--config-settings` on requirement lines so they can be passed to the build backend.

Evaluate automatically activating PEP 517 build for requirements with such options.

### Alternative Solutions

Do not allow configuring build settings per requirements.

### Additional context

https://github.com/pypa/pip/pull/9422#issuecomment-1116047542

### Code of Conduct

- [X] I agree to follow the [PSF Code of Conduct](https://www.python.org/psf/conduct/).

Discussion:

Patch:
diff --git a/news/11325.feature.rst b/news/11325.feature.rst
new file mode 100644
index 000000000..282310816
--- /dev/null
+++ b/news/11325.feature.rst
@@ -0,0 +1,1 @@
+Support a per-requirement ``--config-settings`` option in requirements files.
diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py
index 8c3260132..c2f4e38be 100644
--- a/src/pip/_internal/cli/req_command.py
+++ b/src/pip/_internal/cli/req_command.py
@@ -438,6 +438,9 @@ class RequirementCommand(IndexGroupCommand):
                     isolated=options.isolated_mode,
                     use_pep517=options.use_pep517,
                     user_supplied=True,
+                    config_settings=parsed_req.options.get("config_settings")
+                    if parsed_req.options
+                    else None,
                 )
                 requirements.append(req_to_add)
 
diff --git a/src/pip/_internal/req/constructors.py b/src/pip/_internal/req/constructors.py
index 31c2421d7..c5ca2d85d 100644
--- a/src/pip/_internal/req/constructors.py
+++ b/src/pip/_internal/req/constructors.py
@@ -453,6 +453,7 @@ def install_req_from_parsed_requirement(
     isolated: bool = False,
     use_pep517: Optional[bool] = None,
     user_supplied: bool = False,
+    config_settings: Optional[Dict[str, Union[str, List[str]]]] = None,
 ) -> InstallRequirement:
     if parsed_req.is_editable:
         req = install_req_from_editable(
@@ -462,6 +463,7 @@ def install_req_from_parsed_requirement(
             constraint=parsed_req.constraint,
             isolated=isolated,
             user_supplied=user_supplied,
+            config_settings=config_settings,
         )
 
     else:
@@ -481,6 +483,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
 
diff --git a/src/pip/_internal/req/req_file.py b/src/pip/_internal/req/req_file.py
index aced95e64..18e77b6c0 100644
--- a/src/pip/_internal/req/req_file.py
+++ b/src/pip/_internal/req/req_file.py
@@ -71,6 +71,7 @@ SUPPORTED_OPTIONS: List[Callable[..., optparse.Option]] = [
 SUPPORTED_OPTIONS_REQ: List[Callable[..., optparse.Option]] = [
     cmdoptions.global_options,
     cmdoptions.hash,
+    cmdoptions.config_settings,
 ]
 
 # the 'dest' string values


Test patch:
diff --git a/tests/functional/test_config_settings.py b/tests/functional/test_config_settings.py
index 91643a3dc..f3975de2a 100644
--- a/tests/functional/test_config_settings.py
+++ b/tests/functional/test_config_settings.py
@@ -5,7 +5,7 @@ from typing import List, Optional, Tuple
 from zipfile import ZipFile
 
 from pip._internal.utils.urls import path_to_url
-from tests.lib import PipTestEnvironment
+from tests.lib import PipTestEnvironment, create_basic_sdist_for_package
 
 PYPROJECT_TOML = """\
 [build-system]
@@ -123,6 +123,20 @@ def test_backend_sees_config(script: PipTestEnvironment) -> None:
             assert json.loads(output) == {"FOO": "Hello"}
 
 
+def test_backend_sees_config_reqs(script: PipTestEnvironment) -> None:
+    name, version, project_dir = make_project(script.scratch_path)
+    script.scratch_path.joinpath("reqs.txt").write_text(
+        f"{project_dir} --config-settings FOO=Hello"
+    )
+    script.pip("wheel", "-r", "reqs.txt")
+    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(f"{name}-config.json")
+            assert json.loads(output) == {"FOO": "Hello"}
+
+
 def test_backend_sees_config_via_constraint(script: PipTestEnvironment) -> None:
     name, version, project_dir = make_project(script.scratch_path)
     constraints_file = script.scratch_path / "constraints.txt"
@@ -244,6 +258,17 @@ def test_install_sees_config(script: PipTestEnvironment) -> None:
         assert json.load(f) == {"FOO": "Hello"}
 
 
+def test_install_sees_config_reqs(script: PipTestEnvironment) -> None:
+    name, _, project_dir = make_project(script.scratch_path)
+    script.scratch_path.joinpath("reqs.txt").write_text(
+        f"{project_dir} --config-settings FOO=Hello"
+    )
+    script.pip("install", "-r", "reqs.txt")
+    config = script.site_packages_path / f"{name}-config.json"
+    with open(config, "rb") as f:
+        assert json.load(f) == {"FOO": "Hello"}
+
+
 def test_install_editable_sees_config(script: PipTestEnvironment) -> None:
     name, _, project_dir = make_project(script.scratch_path)
     script.pip(
@@ -256,3 +281,23 @@ def test_install_editable_sees_config(script: PipTestEnvironment) -> None:
     config = script.site_packages_path / f"{name}-config.json"
     with open(config, "rb") as f:
         assert json.load(f) == {"FOO": "Hello"}
+
+
+def test_install_config_reqs(script: PipTestEnvironment) -> None:
+    name, _, project_dir = make_project(script.scratch_path)
+    a_sdist = create_basic_sdist_for_package(
+        script,
+        "foo",
+        "1.0",
+        {"pyproject.toml": PYPROJECT_TOML, "backend/dummy_backend.py": BACKEND_SRC},
+    )
+    script.scratch_path.joinpath("reqs.txt").write_text(
+        f'{project_dir} --config-settings "--build-option=--cffi" '
+        '--config-settings "--build-option=--avx2" '
+        "--config-settings FOO=BAR"
+    )
+    script.pip("install", "--no-index", "-f", str(a_sdist.parent), "-r", "reqs.txt")
+    script.assert_installed(foo="1.0")
+    config = script.site_packages_path / f"{name}-config.json"
+    with open(config, "rb") as f:
+        assert json.load(f) == {"--build-option": ["--cffi", "--avx2"], "FOO": "BAR"}
diff --git a/tests/functional/test_install_reqs.py b/tests/functional/test_install_reqs.py
index 3ad1909fe..96cff0dc5 100644
--- a/tests/functional/test_install_reqs.py
+++ b/tests/functional/test_install_reqs.py
@@ -2,7 +2,7 @@ import json
 import os
 import textwrap
 from pathlib import Path
-from typing import Any, Callable
+from typing import TYPE_CHECKING, Any
 
 import pytest
 
@@ -18,6 +18,11 @@ from tests.lib import (
 )
 from tests.lib.local_repos import local_checkout
 
+if TYPE_CHECKING:
+    from typing import Protocol
+else:
+    Protocol = object
+
 
 class ArgRecordingSdist:
     def __init__(self, sdist_path: Path, args_path: Path) -> None:
@@ -28,33 +33,42 @@ class ArgRecordingSdist:
         return json.loads(self._args_path.read_text())
 
 
+class ArgRecordingSdistMaker(Protocol):
+    def __call__(self, name: str, **kwargs: Any) -> ArgRecordingSdist:
+        ...
+
+
 @pytest.fixture()
 def arg_recording_sdist_maker(
     script: PipTestEnvironment,
-) -> Callable[[str], ArgRecordingSdist]:
-    arg_writing_setup_py = textwrap.dedent(
+) -> ArgRecordingSdistMaker:
+    arg_writing_setup_py_prelude = textwrap.dedent(
         """
         import io
         import json
         import os
         import sys
 
-        from setuptools import setup
-
         args_path = os.path.join(os.environ["OUTPUT_DIR"], "{name}.json")
         with open(args_path, 'w') as f:
             json.dump(sys.argv, f)
-
-        setup(name={name!r}, version="0.1.0")
         """
     )
     output_dir = script.scratch_path.joinpath("args_recording_sdist_maker_output")
     output_dir.mkdir(parents=True)
     script.environ["OUTPUT_DIR"] = str(output_dir)
 
-    def _arg_recording_sdist_maker(name: str) -> ArgRecordingSdist:
-        extra_files = {"setup.py": arg_writing_setup_py.format(name=name)}
-        sdist_path = create_basic_sdist_for_package(script, name, "0.1.0", extra_files)
+    def _arg_recording_sdist_maker(
+        name: str,
+        **kwargs: Any,
+    ) -> ArgRecordingSdist:
+        sdist_path = create_basic_sdist_for_package(
+            script,
+            name,
+            "0.1.0",
+            setup_py_prelude=arg_writing_setup_py_prelude.format(name=name),
+            **kwargs,
+        )
         args_path = output_dir / f"{name}.json"
         return ArgRecordingSdist(sdist_path, args_path)
 
@@ -727,3 +741,79 @@ def test_install_unsupported_wheel_file(
         in result.stderr
     )
     assert len(result.files_created) == 0
+
+
+def test_config_settings_local_to_package(
+    script: PipTestEnvironment,
+    common_wheels: Path,
+    arg_recording_sdist_maker: ArgRecordingSdistMaker,
+) -> None:
+    pyproject_toml = textwrap.dedent(
+        """
+        [build-system]
+        requires = ["setuptools"]
+        build-backend = "setuptools.build_meta"
+        """
+    )
+    simple0_sdist = arg_recording_sdist_maker(
+        "simple0",
+        extra_files={"pyproject.toml": pyproject_toml},
+        depends=["foo"],
+    )
+    foo_sdist = arg_recording_sdist_maker(
+        "foo",
+        extra_files={"pyproject.toml": pyproject_toml},
+    )
+    simple1_sdist = arg_recording_sdist_maker(
+        "simple1",
+        extra_files={"pyproject.toml": pyproject_toml},
+        depends=["bar"],
+    )
+    bar_sdist = arg_recording_sdist_maker(
+        "bar",
+        extra_files={"pyproject.toml": pyproject_toml},
+        depends=["simple3"],
+    )
+    simple3_sdist = arg_recording_sdist_maker(
+        "simple3", extra_files={"pyproject.toml": pyproject_toml}
+    )
+    simple2_sdist = arg_recording_sdist_maker(
+        "simple2",
+        extra_files={"pyproject.toml": pyproject_toml},
+    )
+
+    reqs_file = script.scratch_path.joinpath("reqs.txt")
+    reqs_file.write_text(
+        textwrap.dedent(
+            """
+            simple0 --config-settings "--build-option=--verbose"
+            foo --config-settings "--build-option=--quiet"
+            simple1 --config-settings "--build-option=--verbose"
+            simple2
+            """
+        )
+    )
+
+    script.pip(
+        "install",
+        "--no-index",
+        "-f",
+        script.scratch_path,
+        "-f",
+        common_wheels,
+        "-r",
+        reqs_file,
+    )
+
+    simple0_args = simple0_sdist.args()
+    assert "--verbose" in simple0_args
+    foo_args = foo_sdist.args()
+    assert "--quiet" in foo_args
+    simple1_args = simple1_sdist.args()
+    assert "--verbose" in simple1_args
+    bar_args = bar_sdist.args()
+    assert "--verbose" not in bar_args
+    simple3_args = simple3_sdist.args()
+    assert "--verbose" not in simple3_args
+    simple2_args = simple2_sdist.args()
+    assert "--verbose" not in simple2_args
diff --git a/tests/unit/test_req_file.py b/tests/unit/test_req_file.py
index 4d5bee249..439c41563 100644
--- a/tests/unit/test_req_file.py
+++ b/tests/unit/test_req_file.py
@@ -73,7 +73,13 @@ def parse_reqfile(
         options=options,
         constraint=constraint,
     ):
-        yield install_req_from_parsed_requirement(parsed_req, isolated=isolated)
+        yield install_req_from_parsed_requirement(
+            parsed_req,
+            isolated=isolated,
+            config_settings=parsed_req.options.get("config_settings")
+            if parsed_req.options
+            else None,
+        )
 
 
 def test_read_file_url(tmp_path: Path, session: PipSession) -> None:
@@ -343,10 +349,14 @@ class TestProcessLine:
         assert reqs[0].constraint
 
     def test_options_on_a_requirement_line(self, line_processor: LineProcessor) -> None:
-        line = 'SomeProject --global-option="yo3" --global-option "yo4"'
+        line = (
+            'SomeProject --global-option="yo3" --global-option "yo4" '
+            '--config-settings="yo3=yo4" --config-settings "yo1=yo2"'
+        )
         filename = "filename"
         req = line_processor(line, filename, 1)[0]
         assert req.global_options == ["yo3", "yo4"]
+        assert req.config_settings == {"yo3": "yo4", "yo1": "yo2"}
 
     def test_hash_options(self, line_processor: LineProcessor) -> None:
         """Test the --hash option: mostly its value storage.