Skip to content

Commit 310255e

Browse files
committed
feat(config): Allow ranges in envlist
Implements #3502. Now it is possible to use ranges within the {} of an env specifier such as py3{10-13}. I chose to implement it as a pre-processing string replacement that just replaces the range with a literal enumeration of the range members. This is mainly to avoid more in-depth handling of these ranges when it coto generative environment lists. Also moves CircularChainError from `of_type` to `types` to avoid a circular import error. (kinda ironic :D)
1 parent f5f5cb1 commit 310255e

File tree

8 files changed

+116
-15
lines changed

8 files changed

+116
-15
lines changed

docs/changelog/3502.feature.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add ranges to generative environments such as py3{10-13}. - by :user:`mimre25`

docs/config.rst

+30-9
Original file line numberDiff line numberDiff line change
@@ -1563,7 +1563,7 @@ If you have a large matrix of dependencies, python versions and/or environments
15631563
.. code-block:: ini
15641564
15651565
[tox]
1566-
env_list = py{311,310,39}-django{41,40}-{sqlite,mysql}
1566+
env_list = py3{9-11}-django{41,40}-{sqlite,mysql}
15671567
15681568
[testenv]
15691569
deps =
@@ -1582,24 +1582,45 @@ This will generate the following tox environments:
15821582
15831583
> tox l
15841584
default environments:
1585-
py311-django41-sqlite -> [no description]
1586-
py311-django41-mysql -> [no description]
1587-
py311-django40-sqlite -> [no description]
1588-
py311-django40-mysql -> [no description]
1589-
py310-django41-sqlite -> [no description]
1590-
py310-django41-mysql -> [no description]
1591-
py310-django40-sqlite -> [no description]
1592-
py310-django40-mysql -> [no description]
15931585
py39-django41-sqlite -> [no description]
15941586
py39-django41-mysql -> [no description]
15951587
py39-django40-sqlite -> [no description]
15961588
py39-django40-mysql -> [no description]
1589+
py310-django41-sqlite -> [no description]
1590+
py310-django41-mysql -> [no description]
1591+
py310-django40-sqlite -> [no description]
1592+
py310-django40-mysql -> [no description]
1593+
py311-django41-sqlite -> [no description]
1594+
py311-django41-mysql -> [no description]
1595+
py311-django40-sqlite -> [no description]
1596+
py311-django40-mysql -> [no description]
1597+
1598+
Both enumerations (`{1,2,3}`) and ranges (`{1-3}`) are supported, and can be mixed together:
1599+
.. code-block:: ini
1600+
1601+
[tox]
1602+
env_list = py3{8-10, 11, 13-14}
1603+
1604+
will create the following envs:
1605+
.. code-block:: shell
1606+
1607+
> tox l
1608+
default environments:
1609+
py38 -> [no description]
1610+
py39 -> [no description]
1611+
py310 -> [no description]
1612+
py311 -> [no description]
1613+
py313 -> [no description]
1614+
py314 -> [no description]
1615+
1616+
15971617
15981618
Generative section names
15991619
~~~~~~~~~~~~~~~~~~~~~~~~
16001620

16011621
Suppose you have some binary packages, and need to run tests both in 32 and 64 bits. You also want an environment to
16021622
create your virtual env for the developers.
1623+
This also supports ranges in the same way as generative environment lists.
16031624

16041625
.. code-block:: ini
16051626

src/tox/config/loader/replacer.py

+16-1
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44

55
import logging
66
import os
7+
import re
78
import sys
89
from abc import ABC, abstractmethod
910
from typing import TYPE_CHECKING, Any, Final, Sequence, Union
1011

11-
from tox.config.of_type import CircularChainError
12+
from tox.config.types import CircularChainError
1213
from tox.execute.request import shell_cmd
1314

1415
if TYPE_CHECKING:
@@ -287,9 +288,23 @@ def replace_tty(args: list[str]) -> str:
287288
return (args[0] if len(args) > 0 else "") if sys.stdout.isatty() else args[1] if len(args) > 1 else ""
288289

289290

291+
def expand_ranges(value: str) -> str:
292+
"""Expand ranges in env expressions, eg py3{10-13} -> "py3{10,11,12,13}"""
293+
matches = re.findall(r"((\d+)-(\d+)|\d+)(?:,|})", value)
294+
for src, start_, end_ in matches:
295+
if src and start_ and end_:
296+
start = int(start_)
297+
end = int(end_)
298+
direction = 1 if start < end else -1
299+
expansion = ",".join(str(x) for x in range(start, end + direction, direction))
300+
value = value.replace(src, expansion, 1)
301+
return value
302+
303+
290304
__all__ = [
291305
"MatchExpression",
292306
"MatchRecursionError",
307+
"expand_ranges",
293308
"find_replace_expr",
294309
"load_posargs",
295310
"replace",

src/tox/config/loader/str_convert.py

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from typing import TYPE_CHECKING, Any, Iterator
1111

1212
from tox.config.loader.convert import Convert
13+
from tox.config.loader.replacer import expand_ranges
1314
from tox.config.types import Command, EnvList
1415

1516
if TYPE_CHECKING:
@@ -113,6 +114,7 @@ def to_command(value: str) -> Command | None:
113114
def to_env_list(value: str) -> EnvList:
114115
from tox.config.loader.ini.factor import extend_factors # noqa: PLC0415
115116

117+
value = expand_ranges(value)
116118
elements = list(chain.from_iterable(extend_factors(expr) for expr in value.split("\n")))
117119
return EnvList(elements)
118120

src/tox/config/of_type.py

+1-4
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,13 @@
77
from typing import TYPE_CHECKING, Callable, Generic, Iterable, TypeVar, cast
88

99
from tox.config.loader.api import ConfigLoadArgs, Loader
10+
from tox.config.types import CircularChainError
1011

1112
if TYPE_CHECKING:
1213
from tox.config.loader.convert import Factory
1314
from tox.config.main import Config # pragma: no cover
1415

1516

16-
class CircularChainError(ValueError):
17-
"""circular chain in config"""
18-
19-
2017
T = TypeVar("T")
2118
V = TypeVar("V")
2219

src/tox/config/source/ini_section.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from tox.config.loader.ini.factor import extend_factors
4+
from tox.config.loader.replacer import expand_ranges
45
from tox.config.loader.section import Section
56

67

@@ -15,7 +16,7 @@ def is_test_env(self) -> bool:
1516

1617
@property
1718
def names(self) -> list[str]:
18-
return list(extend_factors(self.name))
19+
return list(extend_factors(expand_ranges(self.name)))
1920

2021

2122
TEST_ENV_PREFIX = "testenv"

src/tox/config/types.py

+4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
from tox.execute.request import shell_cmd
77

88

9+
class CircularChainError(ValueError):
10+
"""circular chain in config"""
11+
12+
913
class Command: # noqa: PLW1641
1014
"""A command to execute."""
1115

tests/config/loader/ini/test_factor.py

+60
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,54 @@ def test_factor_config_no_env_list_creates_env(tox_ini_conf: ToxIniCreator) -> N
178178
assert list(config) == ["py37-django15", "py37-django16", "py36"]
179179

180180

181+
@pytest.mark.parametrize(
182+
("env_list", "expected_envs"),
183+
[
184+
("py3{10-13}", ["py310", "py311", "py312", "py313"]),
185+
("py3{10-11},a", ["py310", "py311", "a"]),
186+
("py3{10-11},a{1-2}", ["py310", "py311", "a1", "a2"]),
187+
("py3{10-12,14}", ["py310", "py311", "py312", "py314"]),
188+
("py3{8-10,12,14-16}", ["py38", "py39", "py310", "py312", "py314", "py315", "py316"]),
189+
(
190+
"py3{10-11}-django1.{3-5}",
191+
[
192+
"py310-django1.3",
193+
"py310-django1.4",
194+
"py310-django1.5",
195+
"py311-django1.3",
196+
"py311-django1.4",
197+
"py311-django1.5",
198+
],
199+
),
200+
(
201+
"py3{10-11, 13}-django1.{3-4, 6}",
202+
[
203+
"py310-django1.3",
204+
"py310-django1.4",
205+
"py310-django1.6",
206+
"py311-django1.3",
207+
"py311-django1.4",
208+
"py311-django1.6",
209+
"py313-django1.3",
210+
"py313-django1.4",
211+
"py313-django1.6",
212+
],
213+
),
214+
("py3{10-11},a{1-2}-b{3-4}", ["py310", "py311", "a1-b3", "a1-b4", "a2-b3", "a2-b4"]),
215+
("py3{13-11}", ["py313", "py312", "py311"]),
216+
],
217+
)
218+
def test_env_list_expands_ranges(env_list: str, expected_envs: list[str], tox_ini_conf: ToxIniCreator) -> None:
219+
config = tox_ini_conf(
220+
f"""
221+
[tox]
222+
env_list = {env_list}
223+
"""
224+
)
225+
226+
assert list(config) == expected_envs
227+
228+
181229
@pytest.mark.parametrize(
182230
("env", "result"),
183231
[
@@ -202,6 +250,18 @@ def test_ini_loader_raw_with_factors(
202250
assert outcome == result
203251

204252

253+
def test_generative_section_name_with_ranges(tox_ini_conf: ToxIniCreator) -> None:
254+
config = tox_ini_conf(
255+
"""
256+
[testenv:py3{11-13}-{black,lint}]
257+
deps-x =
258+
black: black
259+
lint: flake8
260+
""",
261+
)
262+
assert list(config) == ["py311-black", "py311-lint", "py312-black", "py312-lint", "py313-black", "py313-lint"]
263+
264+
205265
def test_generative_section_name(tox_ini_conf: ToxIniCreator) -> None:
206266
config = tox_ini_conf(
207267
"""

0 commit comments

Comments
 (0)