Skip to content

Commit 6c779c5

Browse files
committed
feat: implement multiple constraint files
Fromager now supports multiple constraints files with `-c` / ``--constraints-file`` argument. Multiple constraints are merged and validated. Example: ```console $ fromager \ -c constraints.txt \ -c local-constraints.txt \ -c https://company.example/security-constraints.txt \ bootstrap ... ``` Local and remote constraints are loaded in `WorkContext.setup()` and dumped into a new file `merged-constraints.txt` in `work-dir`. Some internals of `WorkContext` have changed in an API-incompatible way: - `constraints_file` argument is now `constraints_files: tuple[str, ...] = ()` - `WorkContext` now only accepts keyword arguments - `input_constraints_uri` is replaced by `input_constraints_files` Fixes: #1096 Signed-off-by: Christian Heimes <[email protected]>
1 parent b30aae7 commit 6c779c5

10 files changed

Lines changed: 181 additions & 42 deletions

File tree

docs/how-tos/bootstrap-constraints.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,21 @@ production packages.
3535
3636
This will use the constraints in the ``constraints.txt`` file to build the
3737
production packages for ``my-package``.
38+
39+
Multiple constraints and remote constraints
40+
-------------------------------------------
41+
42+
.. versionchanged:: 0.84.0
43+
The ``--constraints-file`` / ``-c`` option now supports an arbitrary
44+
number of arguments.
45+
46+
The ``--constraints-file`` argument can be supplied multiple times. Multiple
47+
occurrences of the same package are merged and validated. For examples
48+
``egg>=1.0`` and ``egg!=1.1.2`` are combined into ``egg>=1.0,!=1.1.2``. An
49+
unsatisfiable combination like ``egg<1.0`` and ``egg>2.0`` is an error.
50+
51+
Fromager can load constraints from `https://` URLs, too.
52+
53+
.. code-block:: console
54+
55+
$ fromager -c constraints.txt -c local-constraints.txt -c https://company.example/security-constraints.txt bootstrap my-package

docs/spelling_wordlist.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ tos
8484
traceback
8585
tracebacks
8686
txt
87+
unsatisfiable
8788
unshare
8889
url
8990
urls

src/fromager/__main__.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,13 @@
133133
@click.option(
134134
"-c",
135135
"--constraints-file",
136+
"constraints_files",
137+
multiple=True,
136138
type=str,
137-
help="location of the constraints file",
139+
help=(
140+
"location of the constraints files. Constraints are merged and "
141+
"checked for conflicts. Supports local path and remote from https://"
142+
),
138143
)
139144
@click.option(
140145
"--cleanup/--no-cleanup",
@@ -177,7 +182,7 @@ def main(
177182
patches_dir: pathlib.Path,
178183
settings_file: pathlib.Path,
179184
settings_dir: pathlib.Path,
180-
constraints_file: str,
185+
constraints_files: tuple[str, ...],
181186
cleanup: bool,
182187
variant: str,
183188
jobs: int | None,
@@ -247,7 +252,7 @@ def main(
247252
logger.info(f"variant: {variant}")
248253
logger.info(f"patches dir: {patches_dir}")
249254
logger.info(f"maximum concurrent jobs: {jobs}")
250-
logger.info(f"constraints file: {constraints_file}")
255+
logger.info(f"constraints files: {', '.join(constraints_files)}")
251256
logger.info(f"network isolation: {network_isolation}")
252257
if build_wheel_server_url:
253258
logger.info(f"external build wheel server: {build_wheel_server_url}")
@@ -267,7 +272,7 @@ def main(
267272
variant=variant,
268273
max_jobs=jobs,
269274
),
270-
constraints_file=constraints_file,
275+
constraints_files=constraints_files,
271276
patches_dir=patches_dir,
272277
sdists_repo=sdists_repo,
273278
wheels_repo=wheels_repo,

src/fromager/constraints.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import pathlib
3+
import typing
34
from collections.abc import Generator
45

56
from packaging.requirements import Requirement
@@ -24,6 +25,9 @@ def __init__(self) -> None:
2425
def __iter__(self) -> Generator[NormalizedName, None, None]:
2526
yield from self._data
2627

28+
def __bool__(self) -> bool:
29+
return bool(self._data)
30+
2731
def __len__(self) -> int:
2832
return len(self._data)
2933

@@ -70,12 +74,20 @@ def add_constraint(self, unparsed: str) -> None:
7074
self._data[canon_name] = req
7175

7276
def load_constraints_file(self, constraints_file: str | pathlib.Path) -> None:
73-
"""Load constraints from a constraints file"""
77+
"""Load constraints from a constraints file or URL"""
7478
logger.info("loading constraints from %s", constraints_file)
7579
content = requirements_file.parse_requirements_file(constraints_file)
7680
for line in content:
7781
self.add_constraint(line)
7882

83+
def dump_constraints(self, output: typing.TextIO) -> None:
84+
"""Dump combined constraints to a text stream"""
85+
# sort by normalized name
86+
for _, req in sorted(self._data.items()):
87+
# write requirement without markers. They have been evaluated
88+
# in add_constraint()
89+
output.write(f"{req.name}{req.specifier}\n")
90+
7991
def get_constraint(self, name: str) -> Requirement | None:
8092
return self._data.get(canonicalize_name(name))
8193

src/fromager/context.py

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
dependency_graph,
2020
external_commands,
2121
packagesettings,
22-
request_session,
2322
)
2423

2524
if typing.TYPE_CHECKING:
@@ -35,12 +34,13 @@
3534
class WorkContext:
3635
def __init__(
3736
self,
37+
*,
3838
active_settings: packagesettings.Settings | None,
39-
constraints_file: str | None,
4039
patches_dir: pathlib.Path,
4140
sdists_repo: pathlib.Path,
4241
wheels_repo: pathlib.Path,
4342
work_dir: pathlib.Path,
43+
constraints_files: tuple[str, ...] = (),
4444
cleanup: bool = True,
4545
variant: str = "cpu",
4646
network_isolation: bool = False,
@@ -59,13 +59,6 @@ def __init__(
5959
max_jobs=max_jobs,
6060
)
6161
self.settings = active_settings
62-
self.input_constraints_uri: str | None
63-
self.constraints = constraints.Constraints()
64-
if constraints_file is not None:
65-
self.input_constraints_uri = constraints_file
66-
self.constraints.load_constraints_file(constraints_file)
67-
else:
68-
self.input_constraints_uri = None
6962
self.sdists_repo = pathlib.Path(sdists_repo).resolve()
7063
self.sdists_downloads = self.sdists_repo / "downloads"
7164
self.sdists_builds = self.sdists_repo / "builds"
@@ -76,6 +69,7 @@ def __init__(
7669
self.wheel_server_dir = self.wheels_repo / "simple"
7770
self.work_dir = pathlib.Path(work_dir).resolve()
7871
self.graph_file = self.work_dir / "graph.json"
72+
self.merged_constraints = self.work_dir / "merged-constraints.txt"
7973
self.uv_cache = self.work_dir / "uv-cache"
8074
self.wheel_server_url = wheel_server_url
8175
self.logs_dir = self.work_dir / "logs"
@@ -86,10 +80,13 @@ def __init__(
8680
self.network_isolation = network_isolation
8781
self.settings_dir = settings_dir
8882

89-
self._constraints_filename = self.work_dir / "constraints.txt"
90-
9183
self.dependency_graph = dependency_graph.DependencyGraph()
9284

85+
self.constraints = constraints.Constraints()
86+
self.input_constraints_files = constraints_files
87+
for constraints_file in self.input_constraints_files:
88+
self.constraints.load_constraints_file(constraints_file)
89+
9390
# storing metrics
9491
self.time_store: dict[str, dict[str, float]] = collections.defaultdict(
9592
dict[str, float]
@@ -135,19 +132,9 @@ def pip_wheel_server_args(self) -> list[str]:
135132

136133
@property
137134
def pip_constraint_args(self) -> list[str]:
138-
if not self.input_constraints_uri:
135+
if not self.constraints:
139136
return []
140-
141-
if self.input_constraints_uri.startswith(("https://", "http://", "file://")):
142-
path_to_constraints_file = self.work_dir / "input-constraints.txt"
143-
if not path_to_constraints_file.exists():
144-
response = request_session.session.get(self.input_constraints_uri)
145-
path_to_constraints_file.write_text(response.text)
146-
else:
147-
path_to_constraints_file = pathlib.Path(self.input_constraints_uri)
148-
149-
path_to_constraints_file = path_to_constraints_file.absolute()
150-
return ["--constraint", os.fspath(path_to_constraints_file)]
137+
return ["--constraint", os.fspath(self.merged_constraints)]
151138

152139
def uv_clean_cache(self, *reqs: Requirement) -> None:
153140
"""Invalidate and clean uv cache for requirements
@@ -202,6 +189,22 @@ def setup(self) -> None:
202189
logger.debug("creating %s", p)
203190
p.mkdir(parents=True)
204191

192+
if self.constraints:
193+
with self.merged_constraints.open("w", encoding="utf-8") as f:
194+
f.write("# auto-generated constraints file\n")
195+
for constraints_file in self.input_constraints_files:
196+
f.write(f"# {constraints_file}\n")
197+
f.write("\n")
198+
self.constraints.dump_constraints(f)
199+
logger.debug(
200+
"generated %s with content %s",
201+
self.merged_constraints,
202+
self.merged_constraints.read_text(),
203+
)
204+
else:
205+
logger.debug("no constraints configured")
206+
self.merged_constraints.unlink(missing_ok=True)
207+
205208
def clean_build_dirs(
206209
self,
207210
sdist_root_dir: pathlib.Path | None,

tests/conftest.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ def tmp_context(tmp_path: pathlib.Path) -> context.WorkContext:
4747
variant = "cpu"
4848
ctx = context.WorkContext(
4949
active_settings=None,
50-
constraints_file=None,
5150
patches_dir=patches_dir,
5251
sdists_repo=tmp_path / "sdists-repo",
5352
wheels_repo=tmp_path / "wheels-repo",
@@ -73,7 +72,6 @@ def testdata_context(
7372
variant=variant,
7473
max_jobs=None,
7574
),
76-
constraints_file=None,
7775
patches_dir=overrides / "patches",
7876
sdists_repo=tmp_path / "sdists-repo",
7977
wheels_repo=tmp_path / "wheels-repo",
@@ -107,7 +105,6 @@ def make_sbom_ctx(
107105
settings._package_settings[ps.name] = ps
108106
return context.WorkContext(
109107
active_settings=settings,
110-
constraints_file=None,
111108
patches_dir=tmp_path / "patches",
112109
sdists_repo=tmp_path / "sdists-repo",
113110
wheels_repo=tmp_path / "wheels-repo",

tests/test_cli.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pathlib
22

3+
import requests_mock
34
from click.testing import CliRunner
45

56
from fromager.__main__ import main as fromager
@@ -96,6 +97,40 @@ def test_output_dir_overridden_by_explicit_flags(
9697
assert not (out / "sdists-repo").exists()
9798

9899

100+
def test_multiple_constraints_files(
101+
tmp_path: pathlib.Path, cli_runner: CliRunner
102+
) -> None:
103+
constraints1 = tmp_path / "constraints1.txt"
104+
constraints1.write_text("foo==1.0\nbar!=2.1.1\n")
105+
constraints2 = tmp_path / "constraints2.txt"
106+
constraints2.write_text("bar>=2.0\n")
107+
108+
url = "https://fromager.test/remote-constraints.txt"
109+
110+
with requests_mock.Mocker() as r:
111+
r.get(
112+
url,
113+
text="remote>=1.0\n",
114+
)
115+
result = cli_runner.invoke(
116+
fromager,
117+
[
118+
"--verbose",
119+
"-c",
120+
str(constraints1),
121+
"--constraints-file",
122+
str(constraints2),
123+
"--constraints-file",
124+
url,
125+
"lint",
126+
],
127+
)
128+
assert result.exit_code == 0, result.output
129+
assert "foo==1.0" in result.output
130+
assert "bar!=2.1.1,>=2.0" in result.output
131+
assert "remote>=1.0" in result.output
132+
133+
99134
KNOWN_COMMANDS: set[str] = {
100135
"bootstrap",
101136
"bootstrap-parallel",

tests/test_constraints.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import io
12
import pathlib
23
from unittest import mock
34

45
import pytest
6+
import requests_mock
57
from packaging import markers
68
from packaging.requirements import Requirement
79
from packaging.version import Version
@@ -11,7 +13,10 @@
1113

1214
def test_constraint_is_satisfied_by() -> None:
1315
c = Constraints()
16+
assert not c
1417
c.add_constraint("foo<=1.1")
18+
assert c
19+
assert len(c) == 1
1520
assert c.is_satisfied_by("foo", Version("1.1"))
1621
assert c.is_satisfied_by("foo", Version("1.0"))
1722
assert c.is_satisfied_by("bar", Version("2.0"))
@@ -91,6 +96,22 @@ def test_add_constraint_conflict() -> None:
9196
assert len(c) == 4 # flit_core, foo, bar, and baz
9297

9398

99+
def test_dump_constraints() -> None:
100+
c = Constraints()
101+
102+
out = io.StringIO()
103+
c.dump_constraints(out)
104+
assert out.getvalue() == ""
105+
106+
c.add_constraint("foo>=1.0")
107+
c.add_constraint("foo<2.0")
108+
c.add_constraint("bar==1.1")
109+
110+
out = io.StringIO()
111+
c.dump_constraints(out)
112+
assert out.getvalue() == "bar==1.1\nfoo<2.0,>=1.0\n"
113+
114+
94115
def test_allow_prerelease() -> None:
95116
c = Constraints()
96117
c.add_constraint("foo>=1.1")
@@ -117,6 +138,18 @@ def test_load_constraints_file(tmp_path: pathlib.Path) -> None:
117138
assert c.get_constraint("torch") == Requirement("torch==3.1.0")
118139

119140

141+
def test_load_constraints_url() -> None:
142+
c = Constraints()
143+
url = "https://fromager.test/remote-constraints.txt"
144+
with requests_mock.Mocker() as r:
145+
r.get(
146+
url,
147+
text="remote>=1.0\n",
148+
)
149+
c.load_constraints_file(url)
150+
assert c.get_constraint("remote") == Requirement("remote>1.0")
151+
152+
120153
def test_invalid_constraints() -> None:
121154
c = Constraints()
122155
with pytest.raises(InvalidConstraintError, match=r".*no specifier"):

0 commit comments

Comments
 (0)