Skip to content

Commit 8736549

Browse files
authored
Enforce constraints during install_package_deps (#2888)
Fix #2386
1 parent d291752 commit 8736549

File tree

6 files changed

+229
-17
lines changed

6 files changed

+229
-17
lines changed

docs/changelog/2386.feature.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
Test environments now recognize boolean config keys ``constrain_package_deps`` (default=true) and ``use_frozen_constraints`` (default=false),
2+
which control how tox generates and applies constraints files when performing ``install_package_deps``.
3+
4+
If ``constrain_package_deps`` is true (default), then tox will write out ``{env_dir}{/}constraints.txt`` and pass it to
5+
``pip`` during ``install_package_deps``. If ``use_frozen_constraints`` is false (default), the constraints will be taken
6+
from the specifications listed under ``deps`` (and inside any requirements or constraints file referenced in ``deps``).
7+
Otherwise, ``list_dependencies_command`` (``pip freeze``) is used to enumerate exact package specifications which will
8+
be written to the constraints file.
9+
10+
In previous releases, conflicting package dependencies would silently override the ``deps`` named in the configuration,
11+
resulting in test runs against unexpected dependency versions, particularly when using tox factors to explicitly test
12+
with different versions of dependencies - by :user:`masenf`.

docs/config.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,25 @@ Pip installer
755755
latest available pre-release of any dependencies without a specified version. If ``false``, pip will only install
756756
final releases of unpinned dependencies.
757757

758+
.. conf::
759+
:keys: constrain_package_deps
760+
:default: true
761+
:version_added: 4.4.0
762+
763+
If ``constrain_package_deps`` is true, then tox will create and use ``{env_dir}{/}constraints.txt`` when installing
764+
package dependnecies during ``install_package_deps`` stage. When this value is set to false, any conflicting package
765+
dependencies will override explicit dependencies and constraints passed to ``deps``.
766+
767+
.. conf::
768+
:keys: use_frozen_constraints
769+
:default: false
770+
:version_added: 4.4.0
771+
772+
When ``use_frozen_constraints`` is true, then tox will use the ``list_dependencies_command`` to enumerate package
773+
versions in order to create ``{env_dir}{/}constraints.txt``. Otherwise the package specifications explicitly listed under
774+
``deps`` (or in requirements / constraints files referenced in ``deps``) will be used as the constraints. If
775+
``constrain_package_deps`` is false, then this setting has no effect.
776+
758777
User configuration
759778
------------------
760779

docs/faq.rst

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -124,21 +124,16 @@ install. While creating a test environment tox will invoke pip multiple times, i
124124
1. install the dependencies of the package.
125125
2. install the package itself.
126126

127-
Some solutions and their drawbacks:
128-
129-
- specify the constraint files within :ref:`deps` (these constraints will not be applied when installing package
130-
dependencies),
131-
- use ``PIP_CONSTRAINT`` inside :ref:`set_env` (tox will not know about the content of the constraint file and such
132-
will not trigger a rebuild of the environment when its content changes),
133-
- specify the constraint file by extending the :ref:`install_command` as in the following example
134-
(tox will not know about the content of the constraint file and such will not trigger a rebuild of the environment
135-
when its content changes).
136-
137-
.. code-block:: ini
138-
139-
[testenv:py39]
140-
install_command = python -m pip install {opts} {packages} -c constraints.txt
141-
extras = test
127+
Starting in tox 4.4.0, ``{env_dir}{/}constraints.txt`` is generated by default during ``install_deps`` based on the
128+
package specifications listed under ``deps``. These constraints are subsequently passed to pip during the
129+
``install_package_deps`` stage, causing an error to be raised when the package dependencies conflict with the test
130+
environment dependencies. For stronger guarantees, set ``use_frozen_constraints = true`` in the test environment to
131+
generate the constraints file based on the exact versions enumerated by the ``list_dependencies_command`` (``pip
132+
freeze``). When using frozen constraints, if the package deps are incompatible with any previously installed
133+
dependency, an error will be raised.
134+
135+
Ensure that ``constrain_package_deps = true`` is set in the test environment in order to use the constraints file
136+
generated by processing the ``deps`` section when performing ``package_deps``.
142137

143138
Note constraint files are a subset of requirement files. Therefore, it's valid to pass a constraint file wherever you
144139
can specify a requirement file.

src/tox/pytest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,7 @@ def register_inline_plugin(mocker: MockerFixture, *args: Callable[..., Any]) ->
525525
"LogCaptureFixture",
526526
"TempPathFactory",
527527
"MonkeyPatch",
528+
"SubRequest",
528529
"ToxRunOutcome",
529530
"ToxProject",
530531
"ToxProjectCreator",

src/tox/tox_env/python/pip/pip_install.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
from collections import defaultdict
5+
from pathlib import Path
56
from typing import Any, Callable, Sequence
67

78
from packaging.requirements import Requirement
@@ -38,6 +39,18 @@ def _register_config(self) -> None:
3839
post_process=self.post_process_install_command,
3940
desc="command used to install packages",
4041
)
42+
self._env.conf.add_config(
43+
keys=["constrain_package_deps"],
44+
of_type=bool,
45+
default=True,
46+
desc="If true, apply constraints during install_package_deps.",
47+
)
48+
self._env.conf.add_config(
49+
keys=["use_frozen_constraints"],
50+
of_type=bool,
51+
default=False,
52+
desc="Use the exact versions of installed deps as constraints, otherwise use the listed deps.",
53+
)
4154
if self._with_list_deps: # pragma: no branch
4255
self._env.conf.add_config(
4356
keys=["list_dependencies_command"],
@@ -81,6 +94,17 @@ def install(self, arguments: Any, section: str, of_type: str) -> None:
8194
logging.warning(f"pip cannot install {arguments!r}")
8295
raise SystemExit(1)
8396

97+
def constraints_file(self) -> Path:
98+
return Path(self._env.env_dir) / "constraints.txt"
99+
100+
@property
101+
def constrain_package_deps(self) -> bool:
102+
return bool(self._env.conf["constrain_package_deps"])
103+
104+
@property
105+
def use_frozen_constraints(self) -> bool:
106+
return bool(self._env.conf["use_frozen_constraints"])
107+
84108
def _install_requirement_file(self, arguments: PythonDeps, section: str, of_type: str) -> None:
85109
try:
86110
new_options, new_reqs = arguments.unroll()
@@ -90,7 +114,16 @@ def _install_requirement_file(self, arguments: PythonDeps, section: str, of_type
90114
new_constraints: list[str] = []
91115
for req in new_reqs:
92116
(new_constraints if req.startswith("-c ") else new_requirements).append(req)
93-
new = {"options": new_options, "requirements": new_requirements, "constraints": new_constraints}
117+
constraint_options = {
118+
"constrain_package_deps": self.constrain_package_deps,
119+
"use_frozen_constraints": self.use_frozen_constraints,
120+
}
121+
new = {
122+
"options": new_options,
123+
"requirements": new_requirements,
124+
"constraints": new_constraints,
125+
"constraint_options": constraint_options,
126+
}
94127
# if option or constraint change in any way recreate, if the requirements change only if some are removed
95128
with self._env.cache.compare(new, section, of_type) as (eq, old):
96129
if not eq: # pragma: no branch
@@ -100,9 +133,16 @@ def _install_requirement_file(self, arguments: PythonDeps, section: str, of_type
100133
missing_requirement = set(old["requirements"]) - set(new_requirements)
101134
if missing_requirement:
102135
raise Recreate(f"requirements removed: {' '.join(missing_requirement)}")
136+
old_constraint_options = old.get("constraint_options")
137+
if old_constraint_options != constraint_options:
138+
msg = f"constraint options changed: old={old_constraint_options} new={constraint_options}"
139+
raise Recreate(msg)
103140
args = arguments.as_root_args
104141
if args: # pragma: no branch
105142
self._execute_installer(args, of_type)
143+
if self.constrain_package_deps and not self.use_frozen_constraints:
144+
combined_constraints = new_requirements + [c.lstrip("-c ") for c in new_constraints]
145+
self.constraints_file().write_text("\n".join(combined_constraints))
106146

107147
@staticmethod
108148
def _recreate_if_diff(of_type: str, new_opts: list[str], old_opts: list[str], fmt: Callable[[str], str]) -> None:
@@ -155,10 +195,19 @@ def _install_list_of_deps(
155195
self._execute_installer(install_args, of_type)
156196

157197
def _execute_installer(self, deps: Sequence[Any], of_type: str) -> None:
198+
if of_type == "package_deps" and self.constrain_package_deps:
199+
constraints_file = self.constraints_file()
200+
if constraints_file.exists():
201+
deps = [*deps, f"-c{constraints_file}"]
202+
158203
cmd = self.build_install_cmd(deps)
159204
outcome = self._env.execute(cmd, stdin=StdinSource.OFF, run_id=f"install_{of_type}")
160205
outcome.assert_success()
161206

207+
if of_type == "deps" and self.constrain_package_deps and self.use_frozen_constraints:
208+
# freeze installed deps for use as constraints
209+
self.constraints_file().write_text("\n".join(self.installed()))
210+
162211
def build_install_cmd(self, args: Sequence[str]) -> list[str]:
163212
try:
164213
cmd: Command = self._env.conf["install_command"]

tests/tox_env/python/pip/test_pip_install.py

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import pytest
99
from packaging.requirements import Requirement
1010

11-
from tox.pytest import CaptureFixture, ToxProjectCreator
11+
from tox.pytest import CaptureFixture, SubRequest, ToxProject, ToxProjectCreator
1212
from tox.tox_env.errors import Fail
1313

1414

@@ -270,3 +270,139 @@ def test_pip_install_constraint_file_new(tox_project: ToxProjectCreator) -> None
270270
assert "py: recreate env because changed constraint(s) added a" in result_second.out, result_second.out
271271
assert execute_calls.call_count == 1
272272
assert execute_calls.call_args[0][3].cmd == ["python", "-I", "-m", "pip", "install", "a", "-c", "c.txt"]
273+
274+
275+
@pytest.fixture(params=[True, False])
276+
def constrain_package_deps(request: SubRequest) -> bool:
277+
return bool(request.param)
278+
279+
280+
@pytest.fixture(params=[True, False])
281+
def use_frozen_constraints(request: SubRequest) -> bool:
282+
return bool(request.param)
283+
284+
285+
@pytest.fixture(
286+
params=[
287+
"explicit",
288+
"requirements",
289+
"constraints",
290+
"explicit+requirements",
291+
"requirements_indirect",
292+
"requirements_constraints_indirect",
293+
],
294+
)
295+
def constrained_mock_project(
296+
request: SubRequest,
297+
tox_project: ToxProjectCreator,
298+
demo_pkg_inline: Path,
299+
constrain_package_deps: bool,
300+
use_frozen_constraints: bool,
301+
) -> tuple[ToxProject, list[str]]:
302+
toml = (demo_pkg_inline / "pyproject.toml").read_text()
303+
files = {
304+
"pyproject.toml": toml.replace("requires = []", 'requires = ["setuptools"]')
305+
+ '\n[project]\nname = "demo"\nversion = "0.1"\ndependencies = ["foo > 2"]',
306+
"build.py": (demo_pkg_inline / "build.py").read_text(),
307+
}
308+
exp_constraints: list[str] = []
309+
requirement = "foo==1.2.3"
310+
constraint = "foo<2"
311+
if request.param == "explicit":
312+
deps = requirement
313+
exp_constraints.append(requirement)
314+
elif request.param == "requirements":
315+
files["requirements.txt"] = f"--pre\n{requirement}"
316+
deps = "-rrequirements.txt"
317+
exp_constraints.append(requirement)
318+
elif request.param == "constraints":
319+
files["constraints.txt"] = constraint
320+
deps = "-cconstraints.txt"
321+
exp_constraints.append(constraint)
322+
elif request.param == "explicit+requirements":
323+
files["requirements.txt"] = f"--pre\n{requirement}"
324+
deps = "\n\t-rrequirements.txt\n\tfoo"
325+
exp_constraints.extend(["foo", requirement])
326+
elif request.param == "requirements_indirect":
327+
files["foo.requirements.txt"] = f"--pre\n{requirement}"
328+
files["requirements.txt"] = "-r foo.requirements.txt"
329+
deps = "-rrequirements.txt"
330+
exp_constraints.append(requirement)
331+
elif request.param == "requirements_constraints_indirect":
332+
files["foo.requirements.txt"] = f"--pre\n{requirement}"
333+
files["foo.constraints.txt"] = f"{constraint}"
334+
files["requirements.txt"] = "-r foo.requirements.txt\n-c foo.constraints.txt"
335+
deps = "-rrequirements.txt"
336+
exp_constraints.extend([requirement, constraint])
337+
else: # pragma: no cover
338+
pytest.fail(f"Missing case: {request.param}")
339+
files["tox.ini"] = (
340+
"[testenv]\npackage=wheel\n"
341+
f"constrain_package_deps = {constrain_package_deps}\n"
342+
f"use_frozen_constraints = {use_frozen_constraints}\n"
343+
f"deps = {deps}"
344+
)
345+
return tox_project(files), exp_constraints if constrain_package_deps else []
346+
347+
348+
def test_constrain_package_deps(
349+
constrained_mock_project: tuple[ToxProject, list[str]],
350+
constrain_package_deps: bool,
351+
use_frozen_constraints: bool,
352+
) -> None:
353+
proj, exp_constraints = constrained_mock_project
354+
execute_calls = proj.patch_execute(lambda r: 0 if "install" in r.run_id else None)
355+
result_first = proj.run("r")
356+
result_first.assert_success()
357+
exp_run_ids = ["install_deps"]
358+
if constrain_package_deps and use_frozen_constraints:
359+
exp_run_ids.append("freeze")
360+
exp_run_ids.extend(
361+
[
362+
"install_requires",
363+
"_optional_hooks",
364+
"get_requires_for_build_wheel",
365+
"build_wheel",
366+
"install_package_deps",
367+
"install_package",
368+
"_exit",
369+
],
370+
)
371+
run_ids = [i[0][3].run_id for i in execute_calls.call_args_list]
372+
assert run_ids == exp_run_ids
373+
constraints_file = proj.path / ".tox" / "py" / "constraints.txt"
374+
if constrain_package_deps:
375+
constraints = constraints_file.read_text().splitlines()
376+
for call in execute_calls.call_args_list:
377+
if call[0][3].run_id == "install_package_deps":
378+
assert f"-c{constraints_file}" in call[0][3].cmd
379+
if use_frozen_constraints:
380+
for c in exp_constraints:
381+
# when using frozen constraints with this mock, the mock package does NOT
382+
# actually end up in the constraints, so assert it's not there
383+
assert c not in constraints
384+
for c in constraints:
385+
assert c.partition("==")[0] in ["pip", "setuptools", "wheel"]
386+
else:
387+
for c in constraints:
388+
assert c in exp_constraints
389+
for c in exp_constraints:
390+
assert c in constraints
391+
else:
392+
assert not constraints_file.exists()
393+
394+
395+
@pytest.mark.parametrize("conf_key", ["constrain_package_deps", "use_frozen_constraints"])
396+
def test_change_constraint_options_recreates(tox_project: ToxProjectCreator, conf_key: str) -> None:
397+
tox_ini_content = "[testenv:py]\ndeps=a\nskip_install=true"
398+
proj = tox_project({"tox.ini": f"{tox_ini_content}\n{conf_key} = true"})
399+
proj.patch_execute(lambda r: 0 if "install" in r.run_id else None)
400+
401+
result = proj.run("r")
402+
result.assert_success()
403+
404+
(proj.path / "tox.ini").write_text(f"{tox_ini_content}\n{conf_key} = false")
405+
result_second = proj.run("r")
406+
result_second.assert_success()
407+
assert "recreate env because constraint options changed" in result_second.out
408+
assert conf_key in result_second.out

0 commit comments

Comments
 (0)