Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/changelog/1160.removal.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Remove ``constraints`` parameter of ``env.DefaultIsolatedEnv.install`` and replace with ``constraints_txt_path``. - by
:user:`layday`
12 changes: 1 addition & 11 deletions src/build/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,17 +217,7 @@ def _bootstrap_build_env(
with DefaultIsolatedEnv(installer=installer, path=env_dir) as env:
builder = ProjectBuilder.from_isolated_env(env, srcdir, runner=runner)

install = env.install
if dependency_constraints_txt:
with open(dependency_constraints_txt, encoding='utf-8') as dependency_constraints_file:
constraints_text = dependency_constraints_file.read()
if constraints_text.strip():
# Passed through as a single element instead of re-parsed into lines, so a requirement can never
# be split from its `--hash` continuation lines (as produced by `pip-compile --generate-hashes`)
# and silently lose its hash check. env.py's installer backends reconstruct the original file via
# `'\n'.join(constraints)` (see `_PipInstaller`/`_UvInstaller.install_dependencies`), a no-op here
# since there is only one element.
install = partial(install, constraints=(constraints_text,))
install = partial(env.install, constraints_txt_path=dependency_constraints_txt)

# first install the build dependencies
install(builder.build_system_requires, _fresh=True)
Expand Down
51 changes: 20 additions & 31 deletions src/build/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
else:
from typing import Self

from . import _types

class _DistArgs(typing.TypedDict, total=False):
path: list[str]

Expand Down Expand Up @@ -209,7 +211,7 @@ def installed_versions(self, requirements: Collection[str]) -> dict[str, str]:
def install(
self,
requirements: Collection[str],
constraints: Collection[str] = (),
constraints_txt_path: _types.StrPath | None = None,
*,
_fresh: bool = False, # Used internally by CLI to support preset PYTHONPATH
) -> None:
Expand All @@ -228,7 +230,7 @@ def install(
'Installing packages in isolated environment:\n' + '\n'.join(f'- {r}' for r in sorted(requirements)),
kind=('step',),
)
self._env_backend.install_dependencies(requirements, constraints, _fresh=_fresh)
self._env_backend.install_dependencies(requirements, constraints_txt_path, _fresh=_fresh)


def _canonical_requirement_name(requirement: str) -> str | None:
Expand All @@ -248,7 +250,7 @@ def create(self, path: str) -> None: ...
def install_dependencies(
self,
requirements: Collection[str],
constraints: Collection[str],
constraints_txt_path: _types.StrPath | None,
*,
_fresh: bool = False,
) -> None: ...
Expand Down Expand Up @@ -393,7 +395,7 @@ def create(self, path: str) -> None:
def install_dependencies(
self,
requirements: Collection[str],
constraints: Collection[str],
constraints_txt_path: _types.StrPath | None,
*,
_fresh: bool = False,
) -> None:
Expand Down Expand Up @@ -421,14 +423,8 @@ def install_dependencies(

cmd += ['-r', os.path.abspath(requirement_file.name)]

if constraints:
with tempfile.NamedTemporaryFile(
'w', prefix='build-constraints-', suffix='.txt', delete=False, encoding='utf-8'
) as constraint_file:
constraint_file.write('\n'.join(constraints))
exit_stack.callback(functools.partial(os.unlink, constraint_file.name))

cmd += ['-c', os.path.abspath(constraint_file.name)]
if constraints_txt_path:
cmd += ['-c', str(constraints_txt_path)]

run_subprocess(cmd, env=_pip_env())

Expand Down Expand Up @@ -465,32 +461,25 @@ def create(self, path: str) -> None:
def install_dependencies( # pragma: no cover -- uv tests are skipped on PyPy, covered on CPython
self,
requirements: Collection[str],
constraints: Collection[str],
constraints_txt_path: _types.StrPath | None,
*,
_fresh: bool = False,
) -> None:
with contextlib.ExitStack() as exit_stack:
cmd = [self._uv_bin, 'pip']

if (verbosity := _ctx.verbosity) > 1:
cmd += [f'-{"v" * min(2, verbosity - 1)}']
cmd = [self._uv_bin, 'pip']

cmd += ['install', *requirements, '--python', self.python_executable]
if (verbosity := _ctx.verbosity) > 1:
cmd += [f'-{"v" * min(2, verbosity - 1)}']

if constraints:
with tempfile.NamedTemporaryFile(
'w', prefix='build-constraints-', suffix='.txt', delete=False, encoding='utf-8'
) as constraint_file:
constraint_file.write('\n'.join(constraints))
exit_stack.callback(functools.partial(os.unlink, constraint_file.name))
cmd += ['install', *requirements, '--python', self.python_executable]

cmd += ['-c', os.path.abspath(constraint_file.name)]
if constraints_txt_path:
cmd += ['-c', str(constraints_txt_path)]

env = {k: v for k, v in os.environ.items() if k != 'PYTHONPATH'}
env['VIRTUAL_ENV'] = self._env_path
if 'UV_KEYRING_PROVIDER' not in os.environ and _has_keyring_cli():
env['UV_KEYRING_PROVIDER'] = 'subprocess'
run_subprocess(cmd, env=env)
env = {k: v for k, v in os.environ.items() if k != 'PYTHONPATH'}
env['VIRTUAL_ENV'] = self._env_path
if 'UV_KEYRING_PROVIDER' not in os.environ and _has_keyring_cli():
env['UV_KEYRING_PROVIDER'] = 'subprocess'
run_subprocess(cmd, env=env)

@property
def display_name(self) -> str:
Expand Down
56 changes: 22 additions & 34 deletions tests/test_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,21 +283,27 @@ def test_install_short_circuits(


@pytest.mark.parametrize('verbosity', range(3))
@pytest.mark.parametrize('constraints', [[], ['foo']])
@pytest.mark.parametrize('constraints_txt', ['', 'foo'])
@pytest.mark.parametrize('fresh', [False, True])
@pytest.mark.usefixtures('local_pip')
def test_default_impl_install_cmd_well_formed(
tmp_path: Path,
mocker: pytest_mock.MockerFixture,
verbosity: int,
constraints: list[str],
constraints_txt: str,
fresh: bool,
) -> None:
mocker.patch.object(_ctx, 'verbosity', verbosity)

constraints_txt_path = None
if constraints_txt:
constraints_txt_path = tmp_path.joinpath('constraints.txt')
constraints_txt_path.write_text(constraints_txt, encoding='utf-8')

with build.env.DefaultIsolatedEnv() as env:
run_subprocess = mocker.patch('build.env.run_subprocess')

env.install(['some', 'requirements'], constraints, _fresh=fresh)
env.install(['some', 'requirements'], constraints_txt_path, _fresh=fresh)

run_subprocess.assert_called_once_with(
[
Expand All @@ -313,28 +319,34 @@ def test_default_impl_install_cmd_well_formed(
'--no-input',
'-r',
mocker.ANY,
*(['-c', mocker.ANY] if constraints else []),
*(['-c', str(constraints_txt_path)] if constraints_txt_path else []),
],
env=mocker.ANY,
)


@pytest.mark.parametrize('verbosity', range(3))
@pytest.mark.parametrize('constraints', [[], ['foo']])
@pytest.mark.parametrize('constraints_txt', ['', 'foo'])
@pytest.mark.parametrize('fresh', [False, True])
@pytest.mark.skipif(MISSING_UV, reason='uv executable not found')
def test_uv_impl_install_cmd_well_formed( # pragma: no cover -- uv tests are skipped on PyPy, covered on CPython
tmp_path: Path,
mocker: pytest_mock.MockerFixture,
verbosity: int,
constraints: list[str],
constraints_txt: str,
fresh: bool,
) -> None:
mocker.patch.object(_ctx, 'verbosity', verbosity)

constraints_txt_path = None
if constraints_txt:
constraints_txt_path = tmp_path.joinpath('constraints.txt')
constraints_txt_path.write_text(constraints_txt, encoding='utf-8')

with build.env.DefaultIsolatedEnv(installer='uv') as env:
run_subprocess = mocker.patch('build.env.run_subprocess')

env.install(['some', 'requirements'], constraints, _fresh=fresh)
env.install(['some', 'requirements'], constraints_txt_path, _fresh=fresh)

run_subprocess.assert_called_once_with(
[
Expand All @@ -346,7 +358,7 @@ def test_uv_impl_install_cmd_well_formed( # pragma: no cover -- uv tests are sk
'requirements',
'--python',
mocker.ANY,
*(['-c', mocker.ANY] if constraints else []),
*(['-c', str(constraints_txt_path)] if constraints_txt_path else []),
],
env=mocker.ANY,
)
Expand All @@ -356,7 +368,7 @@ def test_uv_impl_install_cmd_well_formed( # pragma: no cover -- uv tests are sk

@pytest.mark.usefixtures('local_pip')
def test_default_impl_install_files_line_endings_not_doubled(mocker: pytest_mock.MockerFixture) -> None:
# The requirements/constraints files are opened in text mode, which translates every
# The requirements files are opened in text mode, which translates every
# '\n' written to os.linesep -- '\r\n' on disk is correct on Windows. Joining with
# os.linesep first (instead of '\n') would double-translate there, turning '\r\n' into
# '\r\r\n'. Read back as bytes, before the files are deleted by the
Expand All @@ -368,37 +380,13 @@ def fake_run_subprocess(cmd: list[str], **_kwargs: object) -> None:
for arg in args:
if arg == '-r':
written['requirements'] = Path(next(args)).read_bytes()
elif arg == '-c':
written['constraints'] = Path(next(args)).read_bytes()

with build.env.DefaultIsolatedEnv() as env:
mocker.patch('build.env.run_subprocess', side_effect=fake_run_subprocess)
env.install(['some', 'requirements'], ['a-constraint', 'b-constraint'])
env.install(['some', 'requirements'])

assert b'\r\r' not in written['requirements']
assert written['requirements'].splitlines() == [b'some', b'requirements']
assert b'\r\r' not in written['constraints']
assert written['constraints'].splitlines() == [b'a-constraint', b'b-constraint']


@pytest.mark.skipif(MISSING_UV, reason='uv executable not found')
def test_uv_impl_install_files_line_endings_not_doubled( # pragma: no cover -- skipped on PyPy, covered on CPython
mocker: pytest_mock.MockerFixture,
) -> None:
written: dict[str, bytes] = {}

def fake_run_subprocess(cmd: list[str], **_kwargs: object) -> None:
args = iter(cmd)
for arg in args:
if arg == '-c':
written['constraints'] = Path(next(args)).read_bytes()

with build.env.DefaultIsolatedEnv(installer='uv') as env:
mocker.patch('build.env.run_subprocess', side_effect=fake_run_subprocess)
env.install(['some', 'requirements'], ['a-constraint', 'b-constraint'])

assert b'\r\r' not in written['constraints']
assert written['constraints'].splitlines() == [b'a-constraint', b'b-constraint']


@pytest.mark.usefixtures('local_pip')
Expand Down
47 changes: 5 additions & 42 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,10 +232,10 @@ def test_build_isolated(mocker: pytest_mock.MockerFixture, package_test_flit: st

build.__main__.build_package(package_test_flit, '.', ['sdist'])

install.assert_any_call({'flit_core >=2,<4'}, _fresh=True)
install.assert_any_call({'flit_core >=2,<4'}, constraints_txt_path=None, _fresh=True)

required_cmd.assert_called_with('sdist', None)
install.assert_any_call({'dep1', 'dep2'})
install.assert_any_call({'dep1', 'dep2'}, constraints_txt_path=None)

build_cmd.assert_called_with('sdist', '.', None)

Expand Down Expand Up @@ -465,44 +465,7 @@ def test_build_package_with_constraints(
with pytest.raises(build.BuildBackendException, match=re.escape("Backend 'flit_core.buildapi' is not available.")):
build.__main__.build_package(package_test_flit, tmp_path, ['wheel'], dependency_constraints_txt=constraints_txt_path)

install.assert_any_call({'flit_core >=2,<4'}, constraints=('flit-core==12.34\nfoo==wot\n',), _fresh=True)


@pytest.mark.isolated
def test_build_package_with_constraints_passes_file_through_unmodified(
mocker: pytest_mock.MockerFixture, tmp_path: pathlib.Path, package_test_flit: str
) -> None:
# As produced by e.g. `pip-compile --generate-hashes`: a requirement and its --hash options wrapped onto
# continuation lines. Regression test for the requirement/hash pair being split apart when re-parsed into
# individual lines - the file content must reach the installer byte-for-byte instead.
install = mocker.patch('build.env.DefaultIsolatedEnv.install')

constraints_text = (
'flit-core==12.34 \\\n --hash=sha256:aaaa \\\n --hash=sha256:bbbb\n # via test\nfoo==wot \\\n'
' --hash=sha256:cccc\n'
)
constraints_txt_path = tmp_path.joinpath('constraints.txt')
constraints_txt_path.write_text(constraints_text, encoding='utf-8')

with pytest.raises(build.BuildBackendException, match=re.escape("Backend 'flit_core.buildapi' is not available.")):
build.__main__.build_package(package_test_flit, tmp_path, ['wheel'], dependency_constraints_txt=constraints_txt_path)

install.assert_any_call({'flit_core >=2,<4'}, constraints=(constraints_text,), _fresh=True)


@pytest.mark.isolated
def test_build_package_with_empty_constraints_txt(
mocker: pytest_mock.MockerFixture, tmp_path: pathlib.Path, package_test_flit: str
) -> None:
install = mocker.patch('build.env.DefaultIsolatedEnv.install')

constraints_txt_path = tmp_path.joinpath('constraints.txt')
constraints_txt_path.write_text(' \n\n', encoding='utf-8')

with pytest.raises(build.BuildBackendException, match=re.escape("Backend 'flit_core.buildapi' is not available.")):
build.__main__.build_package(package_test_flit, tmp_path, ['wheel'], dependency_constraints_txt=constraints_txt_path)

install.assert_any_call({'flit_core >=2,<4'}, _fresh=True)
install.assert_any_call({'flit_core >=2,<4'}, constraints_txt_path=constraints_txt_path, _fresh=True)


@pytest.mark.parametrize(
Expand Down Expand Up @@ -959,8 +922,8 @@ def test_bootstrap_build_env_logs_versions(mocker: pytest_mock.MockerFixture) ->
) as result:
assert result is builder

env.install.assert_any_call({'setuptools'}, _fresh=True)
env.install.assert_any_call({'wheel'})
env.install.assert_any_call({'setuptools'}, constraints_txt_path=None, _fresh=True)
env.install.assert_any_call({'wheel'}, constraints_txt_path=None)
log_versions.assert_called_once_with(env, {'setuptools', 'wheel'})


Expand Down