diff --git a/docs/changelog/3502.feature.rst b/docs/changelog/3502.feature.rst new file mode 100644 index 0000000000..47772b8de4 --- /dev/null +++ b/docs/changelog/3502.feature.rst @@ -0,0 +1 @@ +Add support for number ranges in generative environments, more details :ref:`here`. - by :user:`mimre25` diff --git a/docs/config.rst b/docs/config.rst index fb099c3d9d..86e2826880 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -1554,6 +1554,8 @@ Conditional settings Here pip will be always installed as the configuration value is not conditional. black is only used for the ``format`` environment, while ``pytest`` is only installed for the ``py310`` and ``py39`` environments. +.. _generative-environment-list: + Generative environment list ~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -1563,7 +1565,7 @@ If you have a large matrix of dependencies, python versions and/or environments .. code-block:: ini [tox] - env_list = py{311,310,39}-django{41,40}-{sqlite,mysql} + env_list = py3{9-11}-django{41,40}-{sqlite,mysql} [testenv] deps = @@ -1582,24 +1584,49 @@ This will generate the following tox environments: > tox l default environments: - py311-django41-sqlite -> [no description] - py311-django41-mysql -> [no description] - py311-django40-sqlite -> [no description] - py311-django40-mysql -> [no description] - py310-django41-sqlite -> [no description] - py310-django41-mysql -> [no description] - py310-django40-sqlite -> [no description] - py310-django40-mysql -> [no description] py39-django41-sqlite -> [no description] py39-django41-mysql -> [no description] py39-django40-sqlite -> [no description] py39-django40-mysql -> [no description] + py310-django41-sqlite -> [no description] + py310-django41-mysql -> [no description] + py310-django40-sqlite -> [no description] + py310-django40-mysql -> [no description] + py311-django41-sqlite -> [no description] + py311-django41-mysql -> [no description] + py311-django40-sqlite -> [no description] + py311-django40-mysql -> [no description] + +Both enumerations (``{1,2,3}``) and numerical ranges (``{1-3}``) are supported, and can be mixed together: + +.. code-block:: ini + + [tox] + env_list = py3{8-10, 11, 13-14} + +will create the following envs: + +.. code-block:: shell + + > tox l + default environments: + py38 -> [no description] + py39 -> [no description] + py310 -> [no description] + py311 -> [no description] + py313 -> [no description] + py314 -> [no description] + +Negative ranges will also be expanded (``{3-1}`` -> ``{3,2,1}``), however, open ranges such as ``{1-}``, ``{-2}``, ``{a-}``, and ``{-b}`` will not be expanded. + + Generative section names ~~~~~~~~~~~~~~~~~~~~~~~~ Suppose you have some binary packages, and need to run tests both in 32 and 64 bits. You also want an environment to create your virtual env for the developers. +This also supports ranges in the same way as generative environment lists. .. code-block:: ini diff --git a/src/tox/config/loader/ini/factor.py b/src/tox/config/loader/ini/factor.py index 1bb639bbf3..11b4fa66d5 100644 --- a/src/tox/config/loader/ini/factor.py +++ b/src/tox/config/loader/ini/factor.py @@ -66,7 +66,7 @@ def find_factor_groups(value: str) -> Iterator[list[tuple[str, bool]]]: yield result -_FACTOR_RE = re.compile(r"!?[\w._][\w._-]*") +_FACTOR_RE = re.compile(r"(?:!?[\w._][\w._-]*|^$)") def expand_env_with_negation(value: str) -> Iterator[str]: @@ -93,8 +93,22 @@ def is_negated(factor: str) -> bool: return factor.startswith("!") +def expand_ranges(value: str) -> str: + """Expand ranges in env expressions, eg py3{10-13} -> "py3{10,11,12,13}""" + matches = re.findall(r"((\d+)-(\d+)|\d+)(?:,|})", value) + for src, start_, end_ in matches: + if src and start_ and end_: + start = int(start_) + end = int(end_) + direction = 1 if start < end else -1 + expansion = ",".join(str(x) for x in range(start, end + direction, direction)) + value = value.replace(src, expansion, 1) + return value + + __all__ = ( "expand_factors", + "expand_ranges", "extend_factors", "filter_for_env", "find_envs", diff --git a/src/tox/config/loader/replacer.py b/src/tox/config/loader/replacer.py index 05e3c060b5..5d9fd70ebd 100644 --- a/src/tox/config/loader/replacer.py +++ b/src/tox/config/loader/replacer.py @@ -8,7 +8,7 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, Final, Sequence, Union -from tox.config.of_type import CircularChainError +from tox.config.types import CircularChainError from tox.execute.request import shell_cmd if TYPE_CHECKING: diff --git a/src/tox/config/loader/str_convert.py b/src/tox/config/loader/str_convert.py index 65dd46dad7..248afe2685 100644 --- a/src/tox/config/loader/str_convert.py +++ b/src/tox/config/loader/str_convert.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, Iterator from tox.config.loader.convert import Convert +from tox.config.loader.ini.factor import expand_ranges from tox.config.types import Command, EnvList if TYPE_CHECKING: @@ -113,6 +114,7 @@ def to_command(value: str) -> Command | None: def to_env_list(value: str) -> EnvList: from tox.config.loader.ini.factor import extend_factors # noqa: PLC0415 + value = expand_ranges(value) elements = list(chain.from_iterable(extend_factors(expr) for expr in value.split("\n"))) return EnvList(elements) diff --git a/src/tox/config/of_type.py b/src/tox/config/of_type.py index d31922f6c2..4d49d16ef3 100644 --- a/src/tox/config/of_type.py +++ b/src/tox/config/of_type.py @@ -7,16 +7,13 @@ from typing import TYPE_CHECKING, Callable, Generic, Iterable, TypeVar, cast from tox.config.loader.api import ConfigLoadArgs, Loader +from tox.config.types import CircularChainError if TYPE_CHECKING: from tox.config.loader.convert import Factory from tox.config.main import Config # pragma: no cover -class CircularChainError(ValueError): - """circular chain in config""" - - T = TypeVar("T") V = TypeVar("V") diff --git a/src/tox/config/source/ini_section.py b/src/tox/config/source/ini_section.py index d7d26720fc..7ed3267f74 100644 --- a/src/tox/config/source/ini_section.py +++ b/src/tox/config/source/ini_section.py @@ -1,6 +1,6 @@ from __future__ import annotations -from tox.config.loader.ini.factor import extend_factors +from tox.config.loader.ini.factor import expand_ranges, extend_factors from tox.config.loader.section import Section @@ -15,7 +15,7 @@ def is_test_env(self) -> bool: @property def names(self) -> list[str]: - return list(extend_factors(self.name)) + return list(extend_factors(expand_ranges(self.name))) TEST_ENV_PREFIX = "testenv" diff --git a/src/tox/config/types.py b/src/tox/config/types.py index 8d1300aa42..42c2c48149 100644 --- a/src/tox/config/types.py +++ b/src/tox/config/types.py @@ -6,6 +6,10 @@ from tox.execute.request import shell_cmd +class CircularChainError(ValueError): + """circular chain in config""" + + class Command: # noqa: PLW1641 """A command to execute.""" diff --git a/tests/config/loader/ini/test_factor.py b/tests/config/loader/ini/test_factor.py index b73abd03be..5dbc50b576 100644 --- a/tests/config/loader/ini/test_factor.py +++ b/tests/config/loader/ini/test_factor.py @@ -178,6 +178,76 @@ def test_factor_config_no_env_list_creates_env(tox_ini_conf: ToxIniCreator) -> N assert list(config) == ["py37-django15", "py37-django16", "py36"] +@pytest.mark.parametrize( + ("env_list", "expected_envs"), + [ + pytest.param("py3{10-13}", ["py310", "py311", "py312", "py313"], id="Expand positive range"), + pytest.param("py3{10-11},a", ["py310", "py311", "a"], id="Expand range and add additional env"), + pytest.param("py3{10-11},a{1-2}", ["py310", "py311", "a1", "a2"], id="Expand multiple env with ranges"), + pytest.param( + "py3{10-12,14}", + ["py310", "py311", "py312", "py314"], + id="Expand ranges, and allow extra parameter in generator", + ), + pytest.param( + "py3{8-10,12,14-16}", + ["py38", "py39", "py310", "py312", "py314", "py315", "py316"], + id="Expand multiple ranges for one generator", + ), + pytest.param( + "py3{10-11}-django1.{3-5}", + [ + "py310-django1.3", + "py310-django1.4", + "py310-django1.5", + "py311-django1.3", + "py311-django1.4", + "py311-django1.5", + ], + id="Expand ranges and factor multiple environment parts", + ), + pytest.param( + "py3{10-11, 13}-django1.{3-4, 6}", + [ + "py310-django1.3", + "py310-django1.4", + "py310-django1.6", + "py311-django1.3", + "py311-django1.4", + "py311-django1.6", + "py313-django1.3", + "py313-django1.4", + "py313-django1.6", + ], + id="Expand ranges and parameters and factor multiple environment parts", + ), + pytest.param( + "py3{10-11},a{1-2}-b{3-4}", + ["py310", "py311", "a1-b3", "a1-b4", "a2-b3", "a2-b4"], + id="Expand ranges and parameters & factor multiple environment parts for multiple generative environments", + ), + pytest.param("py3{13-11}", ["py313", "py312", "py311"], id="Expand negative ranges"), + pytest.param("3.{10-13}", ["3.10", "3.11", "3.12", "3.13"], id="Expand new-style python envs"), + pytest.param("py3{-11}", ["py3-11"], id="Don't expand left-open numerical range"), + pytest.param("foo{11-}", ["foo11-"], id="Don't expand right-open numerical range"), + pytest.param("foo{a-}", ["fooa-"], id="Don't expand right-open range"), + pytest.param("foo{-a}", ["foo-a"], id="Don't expand left-open range"), + pytest.param("foo{a-11}", ["fooa-11"], id="Don't expand alpha-umerical range"), + pytest.param("foo{13-a}", ["foo13-a"], id="Don't expand numerical-alpha range"), + pytest.param("foo{a-b}", ["fooa-b"], id="Don't expand non-numerical range"), + ], +) +def test_env_list_expands_ranges(env_list: str, expected_envs: list[str], tox_ini_conf: ToxIniCreator) -> None: + config = tox_ini_conf( + f""" + [tox] + env_list = {env_list} + """ + ) + + assert list(config) == expected_envs + + @pytest.mark.parametrize( ("env", "result"), [ @@ -202,6 +272,18 @@ def test_ini_loader_raw_with_factors( assert outcome == result +def test_generative_section_name_with_ranges(tox_ini_conf: ToxIniCreator) -> None: + config = tox_ini_conf( + """ + [testenv:py3{11-13}-{black,lint}] + deps-x = + black: black + lint: flake8 + """, + ) + assert list(config) == ["py311-black", "py311-lint", "py312-black", "py312-lint", "py313-black", "py313-lint"] + + def test_generative_section_name(tox_ini_conf: ToxIniCreator) -> None: config = tox_ini_conf( """