Skip to content

Commit eeaef40

Browse files
chryslewebknjaz
andcommitted
🐛 Use requested constraints for build backend
This fixes a bug that build deps compilation would get the latest version of an unconstrained build requirements list, not taking into account the restricted/requested one. The regression test is implemented against `setuptools < 70.1.0` which is known to inject a dependency on `wheel` (newer `setuptools` vendor it). The idea is that `pyproject.toml` does not have an upper bound for `setuptools` but the CLI arg does. And when this works correctly, the `wheel` entry will be included into the resolved output. Cleaning up `PIP_CONSTRAINT` is implemented manually due to the corner case of a permission error on Windows when accessing a file that we hold a file descriptor to from another subprocess[[1]]. It can be further simplified once the lowest Python version `pip-tools` supports is 3.12 by replacing `delete=False` with `delete_on_close=False` in the `tempfile.NamedTemporaryFile()` context manager initializer. [1]: https://docs.python.org/3/library/tempfile.html#tempfile.NamedTemporaryFile Co-authored-by: Sviatoslav Sydorenko <[email protected]>
1 parent 5330964 commit eeaef40

File tree

3 files changed

+157
-6
lines changed

3 files changed

+157
-6
lines changed

piptools/build.py

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import collections
44
import contextlib
5+
import os
56
import pathlib
67
import sys
78
import tempfile
@@ -119,6 +120,7 @@ def build_project_metadata(
119120
src_file: pathlib.Path,
120121
build_targets: tuple[str, ...],
121122
*,
123+
upgrade_packages: tuple[str, ...] | None = None,
122124
attempt_static_parse: bool,
123125
isolated: bool,
124126
quiet: bool,
@@ -159,7 +161,12 @@ def build_project_metadata(
159161
return project_metadata
160162

161163
src_dir = src_file.parent
162-
with _create_project_builder(src_dir, isolated=isolated, quiet=quiet) as builder:
164+
with _create_project_builder(
165+
src_dir,
166+
upgrade_packages=upgrade_packages,
167+
isolated=isolated,
168+
quiet=quiet,
169+
) as builder:
163170
metadata = _build_project_wheel_metadata(builder)
164171
extras = tuple(metadata.get_all("Provides-Extra") or ())
165172
requirements = tuple(
@@ -180,9 +187,80 @@ def build_project_metadata(
180187
)
181188

182189

190+
@contextlib.contextmanager
191+
def _env_var(
192+
env_var_name: str,
193+
env_var_value: str,
194+
/,
195+
) -> Iterator[None]:
196+
sentinel = object()
197+
original_pip_constraint = os.getenv(env_var_name, sentinel)
198+
pip_constraint_was_unset = original_pip_constraint is sentinel
199+
200+
os.environ[env_var_name] = env_var_value
201+
try:
202+
yield
203+
finally:
204+
if pip_constraint_was_unset:
205+
del os.environ[env_var_name]
206+
return
207+
208+
# Assert here is necessary because MyPy can't infer type
209+
# narrowing in the complex case.
210+
assert isinstance(original_pip_constraint, str)
211+
os.environ[env_var_name] = original_pip_constraint
212+
213+
214+
@contextlib.contextmanager
215+
def _temporary_constraints_file_set_for_pip(
216+
upgrade_packages: tuple[str, ...],
217+
) -> Iterator[None]:
218+
with tempfile.NamedTemporaryFile(
219+
mode="w+t",
220+
delete=False, # FIXME: switch to `delete_on_close` in Python 3.12+
221+
) as tmpfile:
222+
# NOTE: `delete_on_close=False` here (or rather `delete=False`,
223+
# NOTE: temporarily) is important for cross-platform execution. It is
224+
# NOTE: required on Windows so that the underlying `pip install`
225+
# NOTE: invocation by pypa/build will be able to access the constraint
226+
# NOTE: file via a subprocess and not fail installing it due to a
227+
# NOTE: permission error related to this file handle still open in our
228+
# NOTE: parent process. To achieve this, we `.close()` the file
229+
# NOTE: descriptor before we hand off the control to the build frontend
230+
# NOTE: and with `delete_on_close=False`, the
231+
# NOTE: `tempfile.NamedTemporaryFile()` context manager does not remove
232+
# NOTE: it from disk right away.
233+
# NOTE: Due to support of versions below Python 3.12, we are forced to
234+
# NOTE: temporarily resort to using `delete=False`, meaning that the CM
235+
# NOTE: never attempts removing the file from disk, not even on exit.
236+
# NOTE: So we do this manually until we can migrate to using the more
237+
# NOTE: ergonomic argument `delete_on_close=False`.
238+
239+
# Write packages to upgrade to a temporary file to set as
240+
# constraints for the installation to the builder environment,
241+
# in case build requirements are among them
242+
tmpfile.write("\n".join(upgrade_packages))
243+
244+
# FIXME: replace `delete` with `delete_on_close` in Python 3.12+
245+
# FIXME: and replace `.close()` with `.flush()`
246+
tmpfile.close()
247+
248+
try:
249+
with _env_var("PIP_CONSTRAINT", tmpfile.name):
250+
yield
251+
finally:
252+
# FIXME: replace `delete` with `delete_on_close` in Python 3.12+
253+
# FIXME: and drop this manual deletion
254+
os.unlink(tmpfile.name)
255+
256+
183257
@contextlib.contextmanager
184258
def _create_project_builder(
185-
src_dir: pathlib.Path, *, isolated: bool, quiet: bool
259+
src_dir: pathlib.Path,
260+
*,
261+
upgrade_packages: tuple[str, ...] | None = None,
262+
isolated: bool,
263+
quiet: bool,
186264
) -> Iterator[build.ProjectBuilder]:
187265
if quiet:
188266
runner = pyproject_hooks.quiet_subprocess_runner
@@ -193,7 +271,13 @@ def _create_project_builder(
193271
yield build.ProjectBuilder(src_dir, runner=runner)
194272
return
195273

196-
with build.env.DefaultIsolatedEnv() as env:
274+
maybe_pip_constrained_context = (
275+
contextlib.nullcontext()
276+
if upgrade_packages is None
277+
else _temporary_constraints_file_set_for_pip(upgrade_packages)
278+
)
279+
280+
with maybe_pip_constrained_context, build.env.DefaultIsolatedEnv() as env:
197281
builder = build.ProjectBuilder.from_isolated_env(env, src_dir, runner)
198282
env.install(builder.build_system_requires)
199283
env.install(builder.get_requires_for_build("wheel"))

piptools/scripts/compile.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,7 @@ def cli(
370370
metadata = build_project_metadata(
371371
src_file=Path(src_file),
372372
build_targets=build_deps_targets,
373+
upgrade_packages=upgrade_packages,
373374
attempt_static_parse=not bool(build_deps_targets),
374375
isolated=build_isolation,
375376
quiet=log.verbosity <= 0,

tests/test_cli_compile.py

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3431,7 +3431,6 @@ def test_compile_recursive_extras_build_targets(runner, tmp_path, current_resolv
34313431
"""
34323432
)
34333433
)
3434-
(tmp_path / "constraints.txt").write_text("wheel<0.43")
34353434
out = runner.invoke(
34363435
cli,
34373436
[
@@ -3446,15 +3445,82 @@ def test_compile_recursive_extras_build_targets(runner, tmp_path, current_resolv
34463445
"--find-links",
34473446
os.fspath(MINIMAL_WHEELS_PATH),
34483447
os.fspath(tmp_path / "pyproject.toml"),
3449-
"--constraint",
3450-
os.fspath(tmp_path / "constraints.txt"),
34513448
"--output-file",
34523449
"-",
34533450
],
34543451
)
34553452
expected = rf"""foo[footest] @ {tmp_path.as_uri()}
34563453
small-fake-a==0.2
34573454
small-fake-b==0.3
3455+
3456+
# The following packages are considered to be unsafe in a requirements file:
3457+
# setuptools
3458+
"""
3459+
try:
3460+
assert out.exit_code == 0
3461+
assert expected == out.stdout
3462+
except Exception: # pragma: no cover
3463+
print(out.stdout)
3464+
print(out.stderr)
3465+
raise
3466+
3467+
3468+
@backtracking_resolver_only
3469+
def test_compile_build_targets_setuptools_no_wheel_dep(
3470+
runner,
3471+
tmp_path,
3472+
current_resolver,
3473+
):
3474+
"""Check that user requests apply to build dependencies.
3475+
3476+
This verifies that build deps compilation would not use the latest version
3477+
of an unconstrained build requirements list, when the user requested
3478+
restricting them.
3479+
3480+
It is implemented against `setuptools < 70.1.0` which is known to inject a
3481+
dependency on `wheel` (newer `setuptools` vendor it). The idea is that
3482+
`pyproject.toml` does not have an upper bound for `setuptools` but the CLI
3483+
arg does. And when this works correctly, the `wheel` entry will be included
3484+
into the resolved output.
3485+
3486+
This is a regression test for
3487+
https://github.com/jazzband/pip-tools/pull/1681#issuecomment-2212541289.
3488+
"""
3489+
(tmp_path / "pyproject.toml").write_text(
3490+
dedent(
3491+
"""
3492+
[project]
3493+
name = "foo"
3494+
version = "0.0.1"
3495+
dependencies = ["small-fake-a"]
3496+
"""
3497+
)
3498+
)
3499+
(tmp_path / "constraints.txt").write_text("wheel<0.43")
3500+
out = runner.invoke(
3501+
cli,
3502+
[
3503+
"--build-isolation",
3504+
"--no-header",
3505+
"--no-annotate",
3506+
"--no-emit-options",
3507+
"--extra",
3508+
"dev",
3509+
"--build-deps-for",
3510+
"wheel",
3511+
"--find-links",
3512+
os.fspath(MINIMAL_WHEELS_PATH),
3513+
os.fspath(tmp_path / "pyproject.toml"),
3514+
"--constraint",
3515+
os.fspath(tmp_path / "constraints.txt"),
3516+
"--upgrade-package",
3517+
"setuptools < 70.1.0", # setuptools>=70.1.0 doesn't require wheel any more
3518+
"--output-file",
3519+
"-",
3520+
],
3521+
catch_exceptions=True,
3522+
)
3523+
expected = r"""small-fake-a==0.2
34583524
wheel==0.42.0
34593525
34603526
# The following packages are considered to be unsafe in a requirements file:

0 commit comments

Comments
 (0)