Skip to content

Commit e2ff114

Browse files
authored
Add [testenv] constraints option (#3556)
* trivial: Dedent function Signed-off-by: Stephen Finucane <stephen@that.guru> * trivial: Address TODO Signed-off-by: Stephen Finucane <stephen@that.guru> * Remove unnecessary dependency The minimum version is Python 3.9 now. Signed-off-by: Stephen Finucane <stephen@that.guru> * Add '[testenv] constraints' setting This allows users to override constraints files for all installations. Without this, it is not possible to use constraints files that include the package under test without introducing a separate pre-tox processing step to scrub said package from the constraints file. Signed-off-by: Stephen Finucane <stephen@that.guru> --------- Signed-off-by: Stephen Finucane <stephen@that.guru>
1 parent 7138c38 commit e2ff114

File tree

9 files changed

+247
-57
lines changed

9 files changed

+247
-57
lines changed

docs/changelog/3350.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added ``constraints`` to allow specifying constraints files for all dependencies.

docs/config.rst

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -942,15 +942,15 @@ Python run
942942
:keys: deps
943943
:default: <empty list>
944944

945-
Name of the Python dependencies. Installed into the environment prior to project after environment creation, but
945+
Python dependencies. Installed into the environment prior to project after environment creation, but
946946
before package installation. All installer commands are executed using the :ref:`tox_root` as the current working
947947
directory. Each value must be one of:
948948

949949
- a Python dependency as specified by :pep:`440`,
950950
- a `requirement file <https://pip.pypa.io/en/stable/user_guide/#requirements-files>`_ when the value starts with
951-
``-r`` (followed by a file path),
951+
``-r`` (followed by a file path or URL),
952952
- a `constraint file <https://pip.pypa.io/en/stable/user_guide/#constraints-files>`_ when the value starts with
953-
``-c`` (followed by a file path).
953+
``-c`` (followed by a file path or URL).
954954

955955
If you are only defining :pep:`508` requirements (aka no pip requirement files), you should use
956956
:ref:`dependency_groups` instead.
@@ -977,6 +977,21 @@ Python run
977977
-r requirements.txt
978978
-c constraints.txt
979979
980+
.. note::
981+
982+
:ref:`constraints` is the preferred way to specify constraints files since they will apply to package dependencies
983+
also.
984+
985+
.. conf::
986+
:keys: constraints
987+
:default: <empty list>
988+
:version_added: 4.28.0
989+
990+
`Constraints files <https://pip.pypa.io/en/stable/user_guide/#constraints-files>`_ to use during package and
991+
dependency installation. Provided constraints files will be used when installing package dependencies and any
992+
additional dependencies specified in :ref:`deps`, but will not be used when installing the package itself.
993+
Each value must be a file path or URL.
994+
980995
.. conf::
981996
:keys: use_develop, usedevelop
982997
:default: false
@@ -1210,7 +1225,6 @@ Pip installer
12101225
This command will be executed only if executing on Continuous Integrations is detected (for example set environment
12111226
variable ``CI=1``) or if journal is active.
12121227

1213-
12141228
.. conf::
12151229
:keys: pip_pre
12161230
:default: false
@@ -1227,7 +1241,7 @@ Pip installer
12271241

12281242
If ``constrain_package_deps`` is true, then tox will create and use ``{env_dir}{/}constraints.txt`` when installing
12291243
package dependencies during ``install_package_deps`` stage. When this value is set to false, any conflicting package
1230-
dependencies will override explicit dependencies and constraints passed to ``deps``.
1244+
dependencies will override explicit dependencies and constraints passed to :ref:`deps`.
12311245

12321246
.. conf::
12331247
:keys: use_frozen_constraints

pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,7 @@ test = [
9797
"pytest-mock>=3.14",
9898
"pytest-xdist>=3.6.1",
9999
"re-assert>=1.1",
100-
"setuptools>=75.3; python_version<='3.8'",
101-
"setuptools>=75.8; python_version>'3.8'",
100+
"setuptools>=75.8",
102101
"time-machine>=2.15; implementation_name!='pypy'",
103102
"wheel>=0.45.1",
104103
]

src/tox/tox.schema.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,14 @@
289289
},
290290
"deps": {
291291
"type": "string",
292-
"description": "Name of the python dependencies as specified by PEP-440"
292+
"description": "python dependencies with optional version specifiers, as specified by PEP-440"
293+
},
294+
"constraints": {
295+
"type": "array",
296+
"items": {
297+
"type": "string"
298+
},
299+
"description": "constraints to apply to installed python dependencies"
293300
},
294301
"dependency_groups": {
295302
"type": "array",

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

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
import operator
55
from abc import ABC, abstractmethod
66
from collections import defaultdict
7+
from functools import partial
78
from pathlib import Path
8-
from typing import TYPE_CHECKING, Any, Callable, Sequence
9+
from typing import TYPE_CHECKING, Any, Callable, Sequence, cast
910

1011
from packaging.requirements import Requirement
1112

@@ -15,7 +16,7 @@
1516
from tox.tox_env.installer import Installer
1617
from tox.tox_env.python.api import Python
1718
from tox.tox_env.python.package import EditableLegacyPackage, EditablePackage, SdistPackage, WheelPackage
18-
from tox.tox_env.python.pip.req_file import PythonDeps
19+
from tox.tox_env.python.pip.req_file import PythonConstraints, PythonDeps
1920

2021
if TYPE_CHECKING:
2122
from tox.config.main import Config
@@ -52,6 +53,7 @@ class Pip(PythonInstallerListDependencies):
5253

5354
def _register_config(self) -> None:
5455
super()._register_config()
56+
root = self._env.core["toxinidir"]
5557
self._env.conf.add_config(
5658
keys=["pip_pre"],
5759
of_type=bool,
@@ -65,6 +67,13 @@ def _register_config(self) -> None:
6567
post_process=self.post_process_install_command,
6668
desc="command used to install packages",
6769
)
70+
self._env.conf.add_config(
71+
keys=["constraints"],
72+
of_type=PythonConstraints,
73+
factory=partial(PythonConstraints.factory, root),
74+
default=PythonConstraints("", root),
75+
desc="constraints to apply to installed python dependencies",
76+
)
6877
self._env.conf.add_config(
6978
keys=["constrain_package_deps"],
7079
of_type=bool,
@@ -110,6 +119,10 @@ def install(self, arguments: Any, section: str, of_type: str) -> None:
110119
logging.warning("pip cannot install %r", arguments)
111120
raise SystemExit(1)
112121

122+
@property
123+
def constraints(self) -> PythonConstraints:
124+
return cast("PythonConstraints", self._env.conf["constraints"])
125+
113126
def constraints_file(self) -> Path:
114127
return Path(self._env.env_dir) / "constraints.txt"
115128

@@ -121,16 +134,25 @@ def constrain_package_deps(self) -> bool:
121134
def use_frozen_constraints(self) -> bool:
122135
return bool(self._env.conf["use_frozen_constraints"])
123136

124-
def _install_requirement_file(self, arguments: PythonDeps, section: str, of_type: str) -> None: # noqa: C901
137+
def _install_requirement_file(self, arguments: PythonDeps, section: str, of_type: str) -> None:
138+
new_requirements: list[str] = []
139+
new_constraints: list[str] = []
140+
125141
try:
126142
new_options, new_reqs = arguments.unroll()
127143
except ValueError as exception:
128144
msg = f"{exception} for tox env py within deps"
129145
raise Fail(msg) from exception
130-
new_requirements: list[str] = []
131-
new_constraints: list[str] = []
132146
for req in new_reqs:
133147
(new_constraints if req.startswith("-c ") else new_requirements).append(req)
148+
149+
try:
150+
_, new_reqs = self.constraints.unroll()
151+
except ValueError as exception:
152+
msg = f"{exception} for tox env py within constraints"
153+
raise Fail(msg) from exception
154+
new_constraints.extend(new_reqs)
155+
134156
constraint_options = {
135157
"constrain_package_deps": self.constrain_package_deps,
136158
"use_frozen_constraints": self.use_frozen_constraints,
@@ -159,17 +181,10 @@ def _install_requirement_file(self, arguments: PythonDeps, section: str, of_type
159181
raise Recreate(msg)
160182
args = arguments.as_root_args
161183
if args: # pragma: no branch
184+
args.extend(self.constraints.as_root_args)
162185
self._execute_installer(args, of_type)
163186
if self.constrain_package_deps and not self.use_frozen_constraints:
164-
# when we drop Python 3.8 we can use the builtin `.removeprefix`
165-
def remove_prefix(text: str, prefix: str) -> str:
166-
if text.startswith(prefix):
167-
return text[len(prefix) :]
168-
return text
169-
170-
combined_constraints = new_requirements + [
171-
remove_prefix(text=c, prefix="-c ") for c in new_constraints
172-
]
187+
combined_constraints = new_requirements + [c.removeprefix("-c ") for c in new_constraints]
173188
self.constraints_file().write_text("\n".join(combined_constraints))
174189

175190
@staticmethod
@@ -215,13 +230,18 @@ def _install_list_of_deps( # noqa: C901
215230
raise Recreate(msg) # pragma: no branch
216231
new_deps = sorted(set(groups["req"]) - set(old or []))
217232
if new_deps: # pragma: no branch
233+
new_deps.extend(self.constraints.as_root_args)
218234
self._execute_installer(new_deps, req_of_type)
219235
install_args = ["--force-reinstall", "--no-deps"]
220236
if groups["pkg"]:
237+
# we intentionally ignore constraints when installing the package itself
238+
# https://github.com/tox-dev/tox/issues/3550
221239
self._execute_installer(install_args + groups["pkg"], of_type)
222240
if groups["dev_pkg"]:
223241
for entry in groups["dev_pkg"]:
224242
install_args.extend(("-e", str(entry)))
243+
# we intentionally ignore constraints when installing the package itself
244+
# https://github.com/tox-dev/tox/issues/3550
225245
self._execute_installer(install_args, of_type)
226246

227247
def _execute_installer(self, deps: Sequence[Any], of_type: str) -> None:

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

Lines changed: 113 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,26 +56,26 @@ def _is_url_self(self, url: str) -> bool:
5656
def _pre_process(self, content: str) -> ReqFileLines:
5757
for at, line in super()._pre_process(content):
5858
if line.startswith("-r") or (line.startswith("-c") and line[2].isalpha()):
59-
found_line = f"{line[0:2]} {line[2:]}"
59+
found_line = f"{line[0:2]} {line[2:]}" # normalize
6060
else:
6161
found_line = line
6262
yield at, found_line
6363

6464
def lines(self) -> list[str]:
6565
return self._raw.splitlines()
6666

67-
@staticmethod
68-
def _normalize_raw(raw: str) -> str:
67+
@classmethod
68+
def _normalize_raw(cls, raw: str) -> str:
6969
# a line ending in an unescaped \ is treated as a line continuation and the newline following it is effectively
7070
# ignored
7171
raw = "".join(raw.replace("\r", "").split("\\\n"))
7272
# for tox<4 supporting requirement/constraint files via -rreq.txt/-creq.txt
73-
lines: list[str] = [PythonDeps._normalize_line(line) for line in raw.splitlines()]
73+
lines: list[str] = [cls._normalize_line(line) for line in raw.splitlines()]
7474
adjusted = "\n".join(lines)
7575
return f"{adjusted}\n" if raw.endswith("\\\n") else adjusted # preserve trailing newline if input has it
7676

77-
@staticmethod
78-
def _normalize_line(line: str) -> str:
77+
@classmethod
78+
def _normalize_line(cls, line: str) -> str:
7979
arg_match = next(
8080
(
8181
arg
@@ -138,6 +138,113 @@ def factory(cls, root: Path, raw: object) -> PythonDeps:
138138
return cls(raw, root)
139139

140140

141+
class PythonConstraints(RequirementsFile):
142+
def __init__(self, raw: str | list[str] | list[Requirement], root: Path) -> None:
143+
super().__init__(root / "tox.ini", constraint=True)
144+
got = raw if isinstance(raw, str) else "\n".join(str(i) for i in raw)
145+
self._raw = self._normalize_raw(got)
146+
self._unroll: tuple[list[str], list[str]] | None = None
147+
self._req_parser_: RequirementsFile | None = None
148+
149+
@property
150+
def _req_parser(self) -> RequirementsFile:
151+
if self._req_parser_ is None:
152+
self._req_parser_ = RequirementsFile(path=self._path, constraint=True)
153+
return self._req_parser_
154+
155+
def _get_file_content(self, url: str) -> str:
156+
if self._is_url_self(url):
157+
return self._raw
158+
return super()._get_file_content(url)
159+
160+
def _is_url_self(self, url: str) -> bool:
161+
return url == str(self._path)
162+
163+
def _pre_process(self, content: str) -> ReqFileLines:
164+
for at, line in super()._pre_process(content):
165+
if line.startswith("-r") or (line.startswith("-c") and line[2].isalpha()):
166+
found_line = f"{line[0:2]} {line[2:]}" # normalize
167+
else:
168+
found_line = line
169+
yield at, found_line
170+
171+
def lines(self) -> list[str]:
172+
return self._raw.splitlines()
173+
174+
@classmethod
175+
def _normalize_raw(cls, raw: str) -> str:
176+
# a line ending in an unescaped \ is treated as a line continuation and the newline following it is effectively
177+
# ignored
178+
raw = "".join(raw.replace("\r", "").split("\\\n"))
179+
# for tox<4 supporting requirement/constraint files via -rreq.txt/-creq.txt
180+
lines: list[str] = [cls._normalize_line(line) for line in raw.splitlines()]
181+
182+
if any(line.startswith("-") for line in lines):
183+
msg = "only constraints files or URLs can be provided"
184+
raise ValueError(msg)
185+
186+
adjusted = "\n".join([f"-c {line}" for line in lines])
187+
return f"{adjusted}\n" if raw.endswith("\\\n") else adjusted # preserve trailing newline if input has it
188+
189+
@classmethod
190+
def _normalize_line(cls, line: str) -> str:
191+
arg_match = next(
192+
(
193+
arg
194+
for arg in ONE_ARG
195+
if line.startswith(arg)
196+
and len(line) > len(arg)
197+
and not (line[len(arg)].isspace() or line[len(arg)] == "=")
198+
),
199+
None,
200+
)
201+
if arg_match is not None:
202+
values = line[len(arg_match) :]
203+
line = f"{arg_match} {values}"
204+
# escape spaces
205+
escape_match = next((e for e in ONE_ARG_ESCAPE if line.startswith(e) and line[len(e)].isspace()), None)
206+
if escape_match is not None:
207+
# escape not already escaped spaces
208+
escaped = re.sub(r"(?<!\\)(\s)", r"\\\1", line[len(escape_match) + 1 :])
209+
line = f"{line[: len(escape_match)]} {escaped}"
210+
return line
211+
212+
def _parse_requirements(self, opt: Namespace, recurse: bool) -> list[ParsedRequirement]: # noqa: FBT001
213+
# check for any invalid options in the deps list
214+
# (requirements recursively included from other files are not checked)
215+
requirements = super()._parse_requirements(opt, recurse)
216+
for req in requirements:
217+
if req.from_file != str(self.path):
218+
continue
219+
if req.options:
220+
msg = f"Cannot provide options in constraints list, only paths or URL can be provided. ({req})"
221+
raise ValueError(msg)
222+
return requirements
223+
224+
def unroll(self) -> tuple[list[str], list[str]]:
225+
if self._unroll is None:
226+
opts_dict = vars(self.options)
227+
if not self.requirements and opts_dict:
228+
msg = "no dependencies"
229+
raise ValueError(msg)
230+
result_opts: list[str] = [f"{key}={value}" for key, value in opts_dict.items()]
231+
result_req = [str(req) for req in self.requirements]
232+
self._unroll = result_opts, result_req
233+
return self._unroll
234+
235+
@classmethod
236+
def factory(cls, root: Path, raw: object) -> PythonConstraints:
237+
if not (
238+
isinstance(raw, str)
239+
or (
240+
isinstance(raw, list)
241+
and (all(isinstance(i, str) for i in raw) or all(isinstance(i, Requirement) for i in raw))
242+
)
243+
):
244+
raise TypeError(raw)
245+
return cls(raw, root)
246+
247+
141248
ONE_ARG = {
142249
"-i",
143250
"--index-url",

src/tox/tox_env/python/runner.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,11 @@ def register_config(self) -> None:
3434
super().register_config()
3535
root = self.core["toxinidir"]
3636
self.conf.add_config(
37-
keys="deps",
37+
keys=["deps"],
3838
of_type=PythonDeps,
3939
factory=partial(PythonDeps.factory, root),
4040
default=PythonDeps("", root),
41-
desc="Name of the python dependencies as specified by PEP-440",
41+
desc="python dependencies with optional version specifiers, as specified by PEP-440",
4242
)
4343
self.conf.add_config(
4444
keys=["dependency_groups"],

0 commit comments

Comments
 (0)