From f36fd8805cb02d9f4dd1fcbe11d95589d90fc08d Mon Sep 17 00:00:00 2001 From: Manfred Kaiser Date: Thu, 30 Jul 2026 18:52:01 +0200 Subject: [PATCH 01/10] fix: join backslash continuations before parsing --dependency-constraints-txt The constraints file was read with `set(map(str.strip, file))`, treating each physical line as an independent constraint. Files produced by `pip-compile --generate-hashes` (and similar tools) wrap a requirement's --hash options onto backslash-continued lines, so the requirement line and its hashes ended up as separate set elements. Because set iteration order depends on the interpreter's hash seed, writing the set back out to a temporary file for pip/uv sometimes reassembled the continuation correctly and sometimes didn't, silently dropping the --hash line from its requirement in the latter case. A tampered or mismatched package hash could then install without error, depending only on the hash seed of that particular run. Join continuation lines into complete logical lines before deduplicating, so every set element is a self-contained, order-independent constraint. --- docs/changelog/1140.bugfix.rst | 4 +++ src/build/__main__.py | 33 +++++++++++++++++++++++-- tests/test_main_helpers.py | 45 +++++++++++++++++++++++++++++++++- 3 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 docs/changelog/1140.bugfix.rst diff --git a/docs/changelog/1140.bugfix.rst b/docs/changelog/1140.bugfix.rst new file mode 100644 index 000000000..cb1ce9c39 --- /dev/null +++ b/docs/changelog/1140.bugfix.rst @@ -0,0 +1,4 @@ +Parse ``--dependency-constraints-txt`` files respecting backslash line continuations before deduplicating them into a +set, fixing a case where a hashed requirement (e.g. from ``pip-compile --generate-hashes``) could have its ``--hash`` +line separated from its requirement line and silently dropped, depending on the interpreter's hash seed - by +:user:`manfred-kaiser` diff --git a/src/build/__main__.py b/src/build/__main__.py index fdcc9517e..4e85269f5 100644 --- a/src/build/__main__.py +++ b/src/build/__main__.py @@ -204,6 +204,36 @@ def _error(msg: str, code: int = 1) -> NoReturn: # pragma: no cover raise SystemExit(code) +def _parse_constraints_txt(path: os.PathLike[str] | str) -> set[str]: + """ + Parse a pip/uv constraints file into a set of constraint lines. + + Requirement files support backslash line continuations (as produced by, e.g., + ``pip-compile --generate-hashes``, where a package's ``--hash`` options are + wrapped onto continuation lines). These must be joined back into a single + logical line *before* being placed in a set: splitting on physical lines and + only later rejoining with ``'\\n'.join()`` loses the continuation marker's + positional relationship, and because sets do not preserve insertion order, + the rejoined file can scramble unrelated requirement and hash lines together, + or separate a hash from its requirement so it stops applying, silently and + unpredictably (depending on the interpreter's hash seed for that run). + """ + constraints: list[str] = [] + logical_line = '' + with open(path, encoding='utf-8') as constraints_file: + for raw_line in constraints_file: + line = raw_line.strip() + if logical_line: + line = f'{logical_line} {line}' + logical_line = '' + if line.endswith('\\'): + logical_line = line[:-1].strip() + continue + if line and not line.startswith('#'): + constraints.append(line) + return set(constraints) + + @contextlib.contextmanager def _bootstrap_build_env( isolation: bool, @@ -223,8 +253,7 @@ def _bootstrap_build_env( install = env.install if dependency_constraints_txt: - with open(dependency_constraints_txt, encoding='utf-8') as dependency_constraints_file: - install = partial(install, constraints=set(map(str.strip, dependency_constraints_file))) + install = partial(install, constraints=_parse_constraints_txt(dependency_constraints_txt)) # first install the build dependencies install(builder.build_system_requires, _fresh=True) diff --git a/tests/test_main_helpers.py b/tests/test_main_helpers.py index 03ac693b3..4a7e37d9d 100644 --- a/tests/test_main_helpers.py +++ b/tests/test_main_helpers.py @@ -1,8 +1,10 @@ from __future__ import annotations +import pathlib + import pytest -from build.__main__ import _natural_language_list +from build.__main__ import _natural_language_list, _parse_constraints_txt def test_natural_language_list() -> None: @@ -11,3 +13,44 @@ def test_natural_language_list() -> None: assert _natural_language_list(['one', 'two', 'three']) == 'one, two and three' with pytest.raises(IndexError, match='no elements'): _natural_language_list([]) + + +def test_parse_constraints_txt_single_line(tmp_path: pathlib.Path) -> None: + path = tmp_path / 'constraints.txt' + path.write_text('foo==1.0\nbar==2.0\n') + assert _parse_constraints_txt(path) == {'foo==1.0', 'bar==2.0'} + + +def test_parse_constraints_txt_ignores_comments_and_blank_lines(tmp_path: pathlib.Path) -> None: + path = tmp_path / 'constraints.txt' + path.write_text('# a comment\nfoo==1.0\n\nbar==2.0\n') + assert _parse_constraints_txt(path) == {'foo==1.0', 'bar==2.0'} + + +def test_parse_constraints_txt_joins_backslash_continuations(tmp_path: pathlib.Path) -> 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 and losing its hash when the file + # is later reconstructed from a set of independently-parsed physical lines. + path = tmp_path / 'constraints.txt' + path.write_text( + 'editables==0.6 \\\n' + ' --hash=sha256:1163834902381c4613787951c5914800fdf155ae08848a373b8ea5006780977c \\\n' + ' --hash=sha256:d70e4698078a1d033e7786d9c64e5be070d058a67c21417024d38a58ac20aa43\n' + ' # via reprodemo (pyproject.toml::build-system.backend::editable)\n' + 'hatchling==1.31.0 \\\n' + ' --hash=sha256:6b48ad4068a482ed7239b3a8215bc55b47aad3345d58dfc94e553c5d2d46211b \\\n' + ' --hash=sha256:aac80bec8b6fe35e8480f1c335be8910fa210a0e6f735a139be205dadcacb544\n' + ' # via reprodemo (pyproject.toml::build-system.requires)\n' + ) + editables_line = ( + 'editables==0.6 ' + '--hash=sha256:1163834902381c4613787951c5914800fdf155ae08848a373b8ea5006780977c ' + '--hash=sha256:d70e4698078a1d033e7786d9c64e5be070d058a67c21417024d38a58ac20aa43' + ) + hatchling_line = ( + 'hatchling==1.31.0 ' + '--hash=sha256:6b48ad4068a482ed7239b3a8215bc55b47aad3345d58dfc94e553c5d2d46211b ' + '--hash=sha256:aac80bec8b6fe35e8480f1c335be8910fa210a0e6f735a139be205dadcacb544' + ) + assert _parse_constraints_txt(path) == {editables_line, hatchling_line} From b55ede422089445d8c7afdab5fb0481c327b1443 Mon Sep 17 00:00:00 2001 From: Manfred Kaiser Date: Thu, 30 Jul 2026 19:42:26 +0200 Subject: [PATCH 02/10] fix: pass explicit encoding to write_text in new constraints-txt tests CI runs with PYTHONWARNDEFAULTENCODING=1 and treats warnings as errors, so the missing `encoding=` argument on `write_text()` failed the tox jobs with an EncodingWarning. --- tests/test_main_helpers.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_main_helpers.py b/tests/test_main_helpers.py index 4a7e37d9d..26804f17c 100644 --- a/tests/test_main_helpers.py +++ b/tests/test_main_helpers.py @@ -17,13 +17,13 @@ def test_natural_language_list() -> None: def test_parse_constraints_txt_single_line(tmp_path: pathlib.Path) -> None: path = tmp_path / 'constraints.txt' - path.write_text('foo==1.0\nbar==2.0\n') + path.write_text('foo==1.0\nbar==2.0\n', encoding='utf-8') assert _parse_constraints_txt(path) == {'foo==1.0', 'bar==2.0'} def test_parse_constraints_txt_ignores_comments_and_blank_lines(tmp_path: pathlib.Path) -> None: path = tmp_path / 'constraints.txt' - path.write_text('# a comment\nfoo==1.0\n\nbar==2.0\n') + path.write_text('# a comment\nfoo==1.0\n\nbar==2.0\n', encoding='utf-8') assert _parse_constraints_txt(path) == {'foo==1.0', 'bar==2.0'} @@ -41,7 +41,8 @@ def test_parse_constraints_txt_joins_backslash_continuations(tmp_path: pathlib.P 'hatchling==1.31.0 \\\n' ' --hash=sha256:6b48ad4068a482ed7239b3a8215bc55b47aad3345d58dfc94e553c5d2d46211b \\\n' ' --hash=sha256:aac80bec8b6fe35e8480f1c335be8910fa210a0e6f735a139be205dadcacb544\n' - ' # via reprodemo (pyproject.toml::build-system.requires)\n' + ' # via reprodemo (pyproject.toml::build-system.requires)\n', + encoding='utf-8', ) editables_line = ( 'editables==0.6 ' From 557fa0a8112b28c0ae4158363cebfa69e55fddeb Mon Sep 17 00:00:00 2001 From: Manfred Kaiser Date: Thu, 30 Jul 2026 20:05:17 +0200 Subject: [PATCH 03/10] refactor: test through the public build_package API, tighten docstring Test the observable behaviour (constraints passed to env.install) via build_package + dependency_constraints_txt, matching the existing test_build_package_with_constraints, instead of calling the private _parse_constraints_txt helper directly. Also reworded the docstring to explain why the join is needed rather than restating the code, and wrapped it to 120 columns. --- src/build/__main__.py | 14 +++--------- tests/test_main.py | 35 +++++++++++++++++++++++++++++ tests/test_main_helpers.py | 46 +------------------------------------- 3 files changed, 39 insertions(+), 56 deletions(-) diff --git a/src/build/__main__.py b/src/build/__main__.py index 4e85269f5..98ef98264 100644 --- a/src/build/__main__.py +++ b/src/build/__main__.py @@ -206,17 +206,9 @@ def _error(msg: str, code: int = 1) -> NoReturn: # pragma: no cover def _parse_constraints_txt(path: os.PathLike[str] | str) -> set[str]: """ - Parse a pip/uv constraints file into a set of constraint lines. - - Requirement files support backslash line continuations (as produced by, e.g., - ``pip-compile --generate-hashes``, where a package's ``--hash`` options are - wrapped onto continuation lines). These must be joined back into a single - logical line *before* being placed in a set: splitting on physical lines and - only later rejoining with ``'\\n'.join()`` loses the continuation marker's - positional relationship, and because sets do not preserve insertion order, - the rejoined file can scramble unrelated requirement and hash lines together, - or separate a hash from its requirement so it stops applying, silently and - unpredictably (depending on the interpreter's hash seed for that run). + Join backslash-continued lines before deduplicating, so a hashed requirement (e.g. from + ``pip-compile --generate-hashes``) can't have its ``--hash`` options split from its requirement line and lost + when the set is later reassembled, which would silently skip that hash check depending on the hash seed. """ constraints: list[str] = [] logical_line = '' diff --git a/tests/test_main.py b/tests/test_main.py index 75aba5594..43291b91c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -460,6 +460,41 @@ def test_build_package_with_constraints( install.assert_any_call({'flit_core >=2,<4'}, constraints={'flit-core==12.34', 'foo==wot'}, _fresh=True) +@pytest.mark.isolated +def test_build_package_with_constraints_joins_backslash_continuations( + 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 and losing its hash + # when the file is later reassembled from a set of independently-parsed physical lines. + install = mocker.patch('build.env.DefaultIsolatedEnv.install') + + constraints_txt_path = tmp_path.joinpath('constraints.txt') + constraints_txt_path.write_text( + """\ +flit-core==12.34 \\ + --hash=sha256:aaaa \\ + --hash=sha256:bbbb + # via test +foo==wot \\ + --hash=sha256:cccc +""", + 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={ + 'flit-core==12.34 --hash=sha256:aaaa --hash=sha256:bbbb', + 'foo==wot --hash=sha256:cccc', + }, + _fresh=True, + ) + + @pytest.mark.pypy3323bug @pytest.mark.parametrize( ('args', 'output'), diff --git a/tests/test_main_helpers.py b/tests/test_main_helpers.py index 26804f17c..03ac693b3 100644 --- a/tests/test_main_helpers.py +++ b/tests/test_main_helpers.py @@ -1,10 +1,8 @@ from __future__ import annotations -import pathlib - import pytest -from build.__main__ import _natural_language_list, _parse_constraints_txt +from build.__main__ import _natural_language_list def test_natural_language_list() -> None: @@ -13,45 +11,3 @@ def test_natural_language_list() -> None: assert _natural_language_list(['one', 'two', 'three']) == 'one, two and three' with pytest.raises(IndexError, match='no elements'): _natural_language_list([]) - - -def test_parse_constraints_txt_single_line(tmp_path: pathlib.Path) -> None: - path = tmp_path / 'constraints.txt' - path.write_text('foo==1.0\nbar==2.0\n', encoding='utf-8') - assert _parse_constraints_txt(path) == {'foo==1.0', 'bar==2.0'} - - -def test_parse_constraints_txt_ignores_comments_and_blank_lines(tmp_path: pathlib.Path) -> None: - path = tmp_path / 'constraints.txt' - path.write_text('# a comment\nfoo==1.0\n\nbar==2.0\n', encoding='utf-8') - assert _parse_constraints_txt(path) == {'foo==1.0', 'bar==2.0'} - - -def test_parse_constraints_txt_joins_backslash_continuations(tmp_path: pathlib.Path) -> 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 and losing its hash when the file - # is later reconstructed from a set of independently-parsed physical lines. - path = tmp_path / 'constraints.txt' - path.write_text( - 'editables==0.6 \\\n' - ' --hash=sha256:1163834902381c4613787951c5914800fdf155ae08848a373b8ea5006780977c \\\n' - ' --hash=sha256:d70e4698078a1d033e7786d9c64e5be070d058a67c21417024d38a58ac20aa43\n' - ' # via reprodemo (pyproject.toml::build-system.backend::editable)\n' - 'hatchling==1.31.0 \\\n' - ' --hash=sha256:6b48ad4068a482ed7239b3a8215bc55b47aad3345d58dfc94e553c5d2d46211b \\\n' - ' --hash=sha256:aac80bec8b6fe35e8480f1c335be8910fa210a0e6f735a139be205dadcacb544\n' - ' # via reprodemo (pyproject.toml::build-system.requires)\n', - encoding='utf-8', - ) - editables_line = ( - 'editables==0.6 ' - '--hash=sha256:1163834902381c4613787951c5914800fdf155ae08848a373b8ea5006780977c ' - '--hash=sha256:d70e4698078a1d033e7786d9c64e5be070d058a67c21417024d38a58ac20aa43' - ) - hatchling_line = ( - 'hatchling==1.31.0 ' - '--hash=sha256:6b48ad4068a482ed7239b3a8215bc55b47aad3345d58dfc94e553c5d2d46211b ' - '--hash=sha256:aac80bec8b6fe35e8480f1c335be8910fa210a0e6f735a139be205dadcacb544' - ) - assert _parse_constraints_txt(path) == {editables_line, hatchling_line} From 5191895eb2b2c664865df80ea59ff59a8caf0977 Mon Sep 17 00:00:00 2001 From: Manfred Kaiser Date: Fri, 31 Jul 2026 09:00:17 +0200 Subject: [PATCH 04/10] Revert "refactor: test through the public build_package API, tighten docstring" This reverts commit 557fa0a8112b28c0ae4158363cebfa69e55fddeb. --- src/build/__main__.py | 14 +++++++++--- tests/test_main.py | 35 ----------------------------- tests/test_main_helpers.py | 46 +++++++++++++++++++++++++++++++++++++- 3 files changed, 56 insertions(+), 39 deletions(-) diff --git a/src/build/__main__.py b/src/build/__main__.py index 98ef98264..4e85269f5 100644 --- a/src/build/__main__.py +++ b/src/build/__main__.py @@ -206,9 +206,17 @@ def _error(msg: str, code: int = 1) -> NoReturn: # pragma: no cover def _parse_constraints_txt(path: os.PathLike[str] | str) -> set[str]: """ - Join backslash-continued lines before deduplicating, so a hashed requirement (e.g. from - ``pip-compile --generate-hashes``) can't have its ``--hash`` options split from its requirement line and lost - when the set is later reassembled, which would silently skip that hash check depending on the hash seed. + Parse a pip/uv constraints file into a set of constraint lines. + + Requirement files support backslash line continuations (as produced by, e.g., + ``pip-compile --generate-hashes``, where a package's ``--hash`` options are + wrapped onto continuation lines). These must be joined back into a single + logical line *before* being placed in a set: splitting on physical lines and + only later rejoining with ``'\\n'.join()`` loses the continuation marker's + positional relationship, and because sets do not preserve insertion order, + the rejoined file can scramble unrelated requirement and hash lines together, + or separate a hash from its requirement so it stops applying, silently and + unpredictably (depending on the interpreter's hash seed for that run). """ constraints: list[str] = [] logical_line = '' diff --git a/tests/test_main.py b/tests/test_main.py index 43291b91c..75aba5594 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -460,41 +460,6 @@ def test_build_package_with_constraints( install.assert_any_call({'flit_core >=2,<4'}, constraints={'flit-core==12.34', 'foo==wot'}, _fresh=True) -@pytest.mark.isolated -def test_build_package_with_constraints_joins_backslash_continuations( - 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 and losing its hash - # when the file is later reassembled from a set of independently-parsed physical lines. - install = mocker.patch('build.env.DefaultIsolatedEnv.install') - - constraints_txt_path = tmp_path.joinpath('constraints.txt') - constraints_txt_path.write_text( - """\ -flit-core==12.34 \\ - --hash=sha256:aaaa \\ - --hash=sha256:bbbb - # via test -foo==wot \\ - --hash=sha256:cccc -""", - 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={ - 'flit-core==12.34 --hash=sha256:aaaa --hash=sha256:bbbb', - 'foo==wot --hash=sha256:cccc', - }, - _fresh=True, - ) - - @pytest.mark.pypy3323bug @pytest.mark.parametrize( ('args', 'output'), diff --git a/tests/test_main_helpers.py b/tests/test_main_helpers.py index 03ac693b3..26804f17c 100644 --- a/tests/test_main_helpers.py +++ b/tests/test_main_helpers.py @@ -1,8 +1,10 @@ from __future__ import annotations +import pathlib + import pytest -from build.__main__ import _natural_language_list +from build.__main__ import _natural_language_list, _parse_constraints_txt def test_natural_language_list() -> None: @@ -11,3 +13,45 @@ def test_natural_language_list() -> None: assert _natural_language_list(['one', 'two', 'three']) == 'one, two and three' with pytest.raises(IndexError, match='no elements'): _natural_language_list([]) + + +def test_parse_constraints_txt_single_line(tmp_path: pathlib.Path) -> None: + path = tmp_path / 'constraints.txt' + path.write_text('foo==1.0\nbar==2.0\n', encoding='utf-8') + assert _parse_constraints_txt(path) == {'foo==1.0', 'bar==2.0'} + + +def test_parse_constraints_txt_ignores_comments_and_blank_lines(tmp_path: pathlib.Path) -> None: + path = tmp_path / 'constraints.txt' + path.write_text('# a comment\nfoo==1.0\n\nbar==2.0\n', encoding='utf-8') + assert _parse_constraints_txt(path) == {'foo==1.0', 'bar==2.0'} + + +def test_parse_constraints_txt_joins_backslash_continuations(tmp_path: pathlib.Path) -> 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 and losing its hash when the file + # is later reconstructed from a set of independently-parsed physical lines. + path = tmp_path / 'constraints.txt' + path.write_text( + 'editables==0.6 \\\n' + ' --hash=sha256:1163834902381c4613787951c5914800fdf155ae08848a373b8ea5006780977c \\\n' + ' --hash=sha256:d70e4698078a1d033e7786d9c64e5be070d058a67c21417024d38a58ac20aa43\n' + ' # via reprodemo (pyproject.toml::build-system.backend::editable)\n' + 'hatchling==1.31.0 \\\n' + ' --hash=sha256:6b48ad4068a482ed7239b3a8215bc55b47aad3345d58dfc94e553c5d2d46211b \\\n' + ' --hash=sha256:aac80bec8b6fe35e8480f1c335be8910fa210a0e6f735a139be205dadcacb544\n' + ' # via reprodemo (pyproject.toml::build-system.requires)\n', + encoding='utf-8', + ) + editables_line = ( + 'editables==0.6 ' + '--hash=sha256:1163834902381c4613787951c5914800fdf155ae08848a373b8ea5006780977c ' + '--hash=sha256:d70e4698078a1d033e7786d9c64e5be070d058a67c21417024d38a58ac20aa43' + ) + hatchling_line = ( + 'hatchling==1.31.0 ' + '--hash=sha256:6b48ad4068a482ed7239b3a8215bc55b47aad3345d58dfc94e553c5d2d46211b ' + '--hash=sha256:aac80bec8b6fe35e8480f1c335be8910fa210a0e6f735a139be205dadcacb544' + ) + assert _parse_constraints_txt(path) == {editables_line, hatchling_line} From caf72d632fcfcb71be0986dc8e57745806b5153f Mon Sep 17 00:00:00 2001 From: Manfred Kaiser Date: Fri, 31 Jul 2026 09:00:17 +0200 Subject: [PATCH 05/10] Revert "fix: pass explicit encoding to write_text in new constraints-txt tests" This reverts commit b55ede422089445d8c7afdab5fb0481c327b1443. --- tests/test_main_helpers.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/test_main_helpers.py b/tests/test_main_helpers.py index 26804f17c..4a7e37d9d 100644 --- a/tests/test_main_helpers.py +++ b/tests/test_main_helpers.py @@ -17,13 +17,13 @@ def test_natural_language_list() -> None: def test_parse_constraints_txt_single_line(tmp_path: pathlib.Path) -> None: path = tmp_path / 'constraints.txt' - path.write_text('foo==1.0\nbar==2.0\n', encoding='utf-8') + path.write_text('foo==1.0\nbar==2.0\n') assert _parse_constraints_txt(path) == {'foo==1.0', 'bar==2.0'} def test_parse_constraints_txt_ignores_comments_and_blank_lines(tmp_path: pathlib.Path) -> None: path = tmp_path / 'constraints.txt' - path.write_text('# a comment\nfoo==1.0\n\nbar==2.0\n', encoding='utf-8') + path.write_text('# a comment\nfoo==1.0\n\nbar==2.0\n') assert _parse_constraints_txt(path) == {'foo==1.0', 'bar==2.0'} @@ -41,8 +41,7 @@ def test_parse_constraints_txt_joins_backslash_continuations(tmp_path: pathlib.P 'hatchling==1.31.0 \\\n' ' --hash=sha256:6b48ad4068a482ed7239b3a8215bc55b47aad3345d58dfc94e553c5d2d46211b \\\n' ' --hash=sha256:aac80bec8b6fe35e8480f1c335be8910fa210a0e6f735a139be205dadcacb544\n' - ' # via reprodemo (pyproject.toml::build-system.requires)\n', - encoding='utf-8', + ' # via reprodemo (pyproject.toml::build-system.requires)\n' ) editables_line = ( 'editables==0.6 ' From 7bfe73630aaec56d4c6823a7ee93c70cc2f0b79e Mon Sep 17 00:00:00 2001 From: Manfred Kaiser Date: Fri, 31 Jul 2026 09:00:17 +0200 Subject: [PATCH 06/10] Revert "fix: join backslash continuations before parsing --dependency-constraints-txt" This reverts commit f36fd8805cb02d9f4dd1fcbe11d95589d90fc08d. --- docs/changelog/1140.bugfix.rst | 4 --- src/build/__main__.py | 33 ++----------------------- tests/test_main_helpers.py | 45 +--------------------------------- 3 files changed, 3 insertions(+), 79 deletions(-) delete mode 100644 docs/changelog/1140.bugfix.rst diff --git a/docs/changelog/1140.bugfix.rst b/docs/changelog/1140.bugfix.rst deleted file mode 100644 index cb1ce9c39..000000000 --- a/docs/changelog/1140.bugfix.rst +++ /dev/null @@ -1,4 +0,0 @@ -Parse ``--dependency-constraints-txt`` files respecting backslash line continuations before deduplicating them into a -set, fixing a case where a hashed requirement (e.g. from ``pip-compile --generate-hashes``) could have its ``--hash`` -line separated from its requirement line and silently dropped, depending on the interpreter's hash seed - by -:user:`manfred-kaiser` diff --git a/src/build/__main__.py b/src/build/__main__.py index 4e85269f5..fdcc9517e 100644 --- a/src/build/__main__.py +++ b/src/build/__main__.py @@ -204,36 +204,6 @@ def _error(msg: str, code: int = 1) -> NoReturn: # pragma: no cover raise SystemExit(code) -def _parse_constraints_txt(path: os.PathLike[str] | str) -> set[str]: - """ - Parse a pip/uv constraints file into a set of constraint lines. - - Requirement files support backslash line continuations (as produced by, e.g., - ``pip-compile --generate-hashes``, where a package's ``--hash`` options are - wrapped onto continuation lines). These must be joined back into a single - logical line *before* being placed in a set: splitting on physical lines and - only later rejoining with ``'\\n'.join()`` loses the continuation marker's - positional relationship, and because sets do not preserve insertion order, - the rejoined file can scramble unrelated requirement and hash lines together, - or separate a hash from its requirement so it stops applying, silently and - unpredictably (depending on the interpreter's hash seed for that run). - """ - constraints: list[str] = [] - logical_line = '' - with open(path, encoding='utf-8') as constraints_file: - for raw_line in constraints_file: - line = raw_line.strip() - if logical_line: - line = f'{logical_line} {line}' - logical_line = '' - if line.endswith('\\'): - logical_line = line[:-1].strip() - continue - if line and not line.startswith('#'): - constraints.append(line) - return set(constraints) - - @contextlib.contextmanager def _bootstrap_build_env( isolation: bool, @@ -253,7 +223,8 @@ def _bootstrap_build_env( install = env.install if dependency_constraints_txt: - install = partial(install, constraints=_parse_constraints_txt(dependency_constraints_txt)) + with open(dependency_constraints_txt, encoding='utf-8') as dependency_constraints_file: + install = partial(install, constraints=set(map(str.strip, dependency_constraints_file))) # first install the build dependencies install(builder.build_system_requires, _fresh=True) diff --git a/tests/test_main_helpers.py b/tests/test_main_helpers.py index 4a7e37d9d..03ac693b3 100644 --- a/tests/test_main_helpers.py +++ b/tests/test_main_helpers.py @@ -1,10 +1,8 @@ from __future__ import annotations -import pathlib - import pytest -from build.__main__ import _natural_language_list, _parse_constraints_txt +from build.__main__ import _natural_language_list def test_natural_language_list() -> None: @@ -13,44 +11,3 @@ def test_natural_language_list() -> None: assert _natural_language_list(['one', 'two', 'three']) == 'one, two and three' with pytest.raises(IndexError, match='no elements'): _natural_language_list([]) - - -def test_parse_constraints_txt_single_line(tmp_path: pathlib.Path) -> None: - path = tmp_path / 'constraints.txt' - path.write_text('foo==1.0\nbar==2.0\n') - assert _parse_constraints_txt(path) == {'foo==1.0', 'bar==2.0'} - - -def test_parse_constraints_txt_ignores_comments_and_blank_lines(tmp_path: pathlib.Path) -> None: - path = tmp_path / 'constraints.txt' - path.write_text('# a comment\nfoo==1.0\n\nbar==2.0\n') - assert _parse_constraints_txt(path) == {'foo==1.0', 'bar==2.0'} - - -def test_parse_constraints_txt_joins_backslash_continuations(tmp_path: pathlib.Path) -> 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 and losing its hash when the file - # is later reconstructed from a set of independently-parsed physical lines. - path = tmp_path / 'constraints.txt' - path.write_text( - 'editables==0.6 \\\n' - ' --hash=sha256:1163834902381c4613787951c5914800fdf155ae08848a373b8ea5006780977c \\\n' - ' --hash=sha256:d70e4698078a1d033e7786d9c64e5be070d058a67c21417024d38a58ac20aa43\n' - ' # via reprodemo (pyproject.toml::build-system.backend::editable)\n' - 'hatchling==1.31.0 \\\n' - ' --hash=sha256:6b48ad4068a482ed7239b3a8215bc55b47aad3345d58dfc94e553c5d2d46211b \\\n' - ' --hash=sha256:aac80bec8b6fe35e8480f1c335be8910fa210a0e6f735a139be205dadcacb544\n' - ' # via reprodemo (pyproject.toml::build-system.requires)\n' - ) - editables_line = ( - 'editables==0.6 ' - '--hash=sha256:1163834902381c4613787951c5914800fdf155ae08848a373b8ea5006780977c ' - '--hash=sha256:d70e4698078a1d033e7786d9c64e5be070d058a67c21417024d38a58ac20aa43' - ) - hatchling_line = ( - 'hatchling==1.31.0 ' - '--hash=sha256:6b48ad4068a482ed7239b3a8215bc55b47aad3345d58dfc94e553c5d2d46211b ' - '--hash=sha256:aac80bec8b6fe35e8480f1c335be8910fa210a0e6f735a139be205dadcacb544' - ) - assert _parse_constraints_txt(path) == {editables_line, hatchling_line} From 64a84cc8382ee31314600b86592c5f4b091afa04 Mon Sep 17 00:00:00 2001 From: Manfred Kaiser Date: Fri, 31 Jul 2026 10:08:18 +0200 Subject: [PATCH 07/10] fix: pass --dependency-constraints-txt through unmodified instead of re-parsing The previous fix correctly joined backslash continuations before deduplicating the file into a set, but still round-tripped the content through Python (parse into lines, dedup into a set, rejoin) before writing it back out for pip/uv. That round trip is what made the original bug possible in the first place. Read the file once and pass its content through as a single, untouched collection element instead: the installer backends' '\n'.join(constraints) then reproduces it byte-for-byte, so a hashed requirement's --hash continuation lines can no longer be split from it, reordered, or deduplicated away, regardless of how it's formatted. --- docs/changelog/1140.bugfix.rst | 4 ++++ src/build/__main__.py | 8 +++++++- tests/test_main.py | 24 +++++++++++++++++++++++- 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 docs/changelog/1140.bugfix.rst diff --git a/docs/changelog/1140.bugfix.rst b/docs/changelog/1140.bugfix.rst new file mode 100644 index 000000000..9dfdd4359 --- /dev/null +++ b/docs/changelog/1140.bugfix.rst @@ -0,0 +1,4 @@ +Pass ``--dependency-constraints-txt`` files through to the installer unmodified instead of re-parsing them into a +deduplicated set of lines, fixing a case where a hashed requirement (e.g. from ``pip-compile --generate-hashes``) +could have its ``--hash`` continuation line separated from its requirement line and silently dropped, depending on +the interpreter's hash seed - by :user:`manfred-kaiser` diff --git a/src/build/__main__.py b/src/build/__main__.py index fdcc9517e..907e76b76 100644 --- a/src/build/__main__.py +++ b/src/build/__main__.py @@ -224,7 +224,13 @@ def _bootstrap_build_env( install = env.install if dependency_constraints_txt: with open(dependency_constraints_txt, encoding='utf-8') as dependency_constraints_file: - install = partial(install, constraints=set(map(str.strip, dependency_constraints_file))) + constraints_text = dependency_constraints_file.read() + if constraints_text.strip(): + # Passed through as a single element so the installer backends' `'\n'.join(constraints)` reproduces + # the file byte-for-byte, instead of re-parsing it into individual lines (which can split a + # requirement from its `--hash` continuation lines, e.g. from `pip-compile --generate-hashes`, + # and silently drop the hash check). + install = partial(install, constraints=(constraints_text,)) # first install the build dependencies install(builder.build_system_requires, _fresh=True) diff --git a/tests/test_main.py b/tests/test_main.py index 75aba5594..70ba545ab 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -457,7 +457,29 @@ 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', 'foo==wot'}, _fresh=True) + 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.pypy3323bug From bd67f43c33985680f5b5134b9e55e3db49e3433b Mon Sep 17 00:00:00 2001 From: Manfred Kaiser Date: Fri, 31 Jul 2026 10:27:50 +0200 Subject: [PATCH 08/10] test: cover the empty-constraints-file branch CI's 100% branch-coverage gate caught what the local partial test runs didn't: nothing exercised the case where --dependency-constraints-txt points at an empty (or whitespace-only) file, so install() is called without a constraints override. --- tests/test_main.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_main.py b/tests/test_main.py index 70ba545ab..348e196d3 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -482,6 +482,21 @@ def test_build_package_with_constraints_passes_file_through_unmodified( 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) + + @pytest.mark.pypy3323bug @pytest.mark.parametrize( ('args', 'output'), From 2b223313adfbb118e321b0c466cc85b4e2edbe90 Mon Sep 17 00:00:00 2001 From: Manfred Kaiser Date: Fri, 31 Jul 2026 10:37:19 +0200 Subject: [PATCH 09/10] style: reformat changelog fragment with docstrfmt --- docs/changelog/1140.bugfix.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/changelog/1140.bugfix.rst b/docs/changelog/1140.bugfix.rst index 9dfdd4359..821a0d9f3 100644 --- a/docs/changelog/1140.bugfix.rst +++ b/docs/changelog/1140.bugfix.rst @@ -1,4 +1,4 @@ Pass ``--dependency-constraints-txt`` files through to the installer unmodified instead of re-parsing them into a -deduplicated set of lines, fixing a case where a hashed requirement (e.g. from ``pip-compile --generate-hashes``) -could have its ``--hash`` continuation line separated from its requirement line and silently dropped, depending on -the interpreter's hash seed - by :user:`manfred-kaiser` +deduplicated set of lines, fixing a case where a hashed requirement (e.g. from ``pip-compile --generate-hashes``) could +have its ``--hash`` continuation line separated from its requirement line and silently dropped, depending on the +interpreter's hash seed - by :user:`manfred-kaiser` From 7be3d5725f9786435cb3a83a352fb56eafe64833 Mon Sep 17 00:00:00 2001 From: Manfred Kaiser Date: Sat, 1 Aug 2026 09:27:27 +0200 Subject: [PATCH 10/10] docs: clarify where the constraints join happens in the code comment Points to env.py's installer backends instead of the ambiguous 'installer backends' `'\n'.join(constraints)`' phrasing that left readers unsure who does the joining. --- src/build/__main__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/build/__main__.py b/src/build/__main__.py index 907e76b76..37e5afa06 100644 --- a/src/build/__main__.py +++ b/src/build/__main__.py @@ -226,10 +226,11 @@ def _bootstrap_build_env( 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 so the installer backends' `'\n'.join(constraints)` reproduces - # the file byte-for-byte, instead of re-parsing it into individual lines (which can split a - # requirement from its `--hash` continuation lines, e.g. from `pip-compile --generate-hashes`, - # and silently drop the hash check). + # 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,)) # first install the build dependencies