Skip to content

Commit f1e461d

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 <cheimes@redhat.com>
1 parent b30aae7 commit f1e461d

10 files changed

Lines changed: 156 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: 12 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,19 @@ 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+
# sort by normalized name
85+
for _, req in sorted(self._data.items()):
86+
# write requirement without markers. They have been evaluated
87+
# in add_constraint()
88+
output.write(f"{req.name}{req.specifier}\n")
89+
7990
def get_constraint(self, name: str) -> Requirement | None:
8091
return self._data.get(canonicalize_name(name))
8192

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: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,30 @@ def test_output_dir_overridden_by_explicit_flags(
9696
assert not (out / "sdists-repo").exists()
9797

9898

99+
def test_multiple_constraints_files(
100+
tmp_path: pathlib.Path, cli_runner: CliRunner
101+
) -> None:
102+
constraints1 = tmp_path / "constraints1.txt"
103+
constraints1.write_text("foo==1.0\nbar!=2.1.1\n")
104+
constraints2 = tmp_path / "constraints2.txt"
105+
constraints2.write_text("bar>=2.0\n")
106+
107+
result = cli_runner.invoke(
108+
fromager,
109+
[
110+
"--verbose",
111+
"-c",
112+
str(constraints1),
113+
"--constraints-file",
114+
str(constraints2),
115+
"lint",
116+
],
117+
)
118+
assert result.exit_code == 0, result.output
119+
assert "foo==1.0" in result.output
120+
assert "bar!=2.1.1,>=2.0" in result.output
121+
122+
99123
KNOWN_COMMANDS: set[str] = {
100124
"bootstrap",
101125
"bootstrap-parallel",

tests/test_constraints.py

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

@@ -11,7 +12,10 @@
1112

1213
def test_constraint_is_satisfied_by() -> None:
1314
c = Constraints()
15+
assert not c
1416
c.add_constraint("foo<=1.1")
17+
assert c
18+
assert len(c) == 1
1519
assert c.is_satisfied_by("foo", Version("1.1"))
1620
assert c.is_satisfied_by("foo", Version("1.0"))
1721
assert c.is_satisfied_by("bar", Version("2.0"))
@@ -91,6 +95,22 @@ def test_add_constraint_conflict() -> None:
9195
assert len(c) == 4 # flit_core, foo, bar, and baz
9296

9397

98+
def test_dump_constraints() -> None:
99+
c = Constraints()
100+
101+
out = io.StringIO()
102+
c.dump_constraints(out)
103+
assert out.getvalue() == ""
104+
105+
c.add_constraint("foo>=1.0")
106+
c.add_constraint("foo<2.0")
107+
c.add_constraint("bar==1.1")
108+
109+
out = io.StringIO()
110+
c.dump_constraints(out)
111+
assert out.getvalue() == "bar==1.1\nfoo<2.0,>=1.0\n"
112+
113+
94114
def test_allow_prerelease() -> None:
95115
c = Constraints()
96116
c.add_constraint("foo>=1.1")

tests/test_context.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@
1010

1111
def _make_context(
1212
tmp_path: pathlib.Path,
13-
constraints_file: str | None = None,
13+
constraints_files: tuple[str, ...] = (),
1414
wheel_server_url: str = "",
1515
cleanup: bool = True,
1616
) -> context.WorkContext:
1717
return context.WorkContext(
1818
active_settings=None,
19-
constraints_file=constraints_file,
19+
constraints_files=constraints_files,
2020
patches_dir=tmp_path / "overrides/patches",
2121
sdists_repo=tmp_path / "sdists-repo",
2222
wheels_repo=tmp_path / "wheels-repo",
@@ -43,16 +43,57 @@ def _all_setup_dirs(ctx: context.WorkContext) -> list[pathlib.Path]:
4343

4444
def test_pip_constraints_args(tmp_path: pathlib.Path) -> None:
4545
constraints_file = tmp_path / "constraints.txt"
46-
constraints_file.write_text("\n") # the file has to exist
47-
ctx = _make_context(tmp_path, constraints_file=str(constraints_file))
46+
constraints_file.write_text("test==1.0\n")
47+
ctx = _make_context(tmp_path, constraints_files=(str(constraints_file),))
4848
ctx.setup()
49-
assert ["--constraint", os.fspath(constraints_file)] == ctx.pip_constraint_args
49+
assert ctx.merged_constraints == tmp_path / "work-dir" / "merged-constraints.txt"
50+
assert ctx.pip_constraint_args == [
51+
"--constraint",
52+
os.fspath(ctx.merged_constraints),
53+
]
54+
55+
assert ctx.merged_constraints.read_text() == "\n".join(
56+
(
57+
"# auto-generated constraints file",
58+
f"# {constraints_file}",
59+
"",
60+
"test==1.0",
61+
"",
62+
)
63+
)
5064

5165
ctx = _make_context(tmp_path)
5266
ctx.setup()
5367
assert [] == ctx.pip_constraint_args
5468

5569

70+
def test_multiple_constraints_files(tmp_path: pathlib.Path) -> None:
71+
constraints1 = tmp_path / "constraints1.txt"
72+
constraints1.write_text("test==1.0\n")
73+
constraints2 = tmp_path / "constraints2.txt"
74+
constraints2.write_text("foo>=2.0\n")
75+
constraints3 = tmp_path / "constraints3.txt"
76+
constraints3.write_text("foo!=2.1.1\n")
77+
ctx = _make_context(
78+
tmp_path,
79+
constraints_files=(str(constraints1), str(constraints2), str(constraints3)),
80+
)
81+
ctx.setup()
82+
83+
assert ctx.merged_constraints.read_text() == "\n".join(
84+
(
85+
"# auto-generated constraints file",
86+
f"# {constraints1}",
87+
f"# {constraints2}",
88+
f"# {constraints3}",
89+
"",
90+
"foo!=2.1.1,>=2.0",
91+
"test==1.0",
92+
"",
93+
)
94+
)
95+
96+
5697
def test_setup_creates_directories(tmp_path: pathlib.Path) -> None:
5798
ctx = _make_context(tmp_path)
5899
ctx.setup()

0 commit comments

Comments
 (0)