From bd4fa95dbd20632d69745671e3670672f10e4dc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Br=C3=A4nnlund?= Date: Sat, 19 Aug 2023 02:01:46 +0200 Subject: [PATCH 1/2] Fix environment regression --- docs/changelog/3099.bugfix.rst | 1 + src/tox/session/env_select.py | 22 ++++++++++++++----- tests/session/test_env_select.py | 37 ++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 docs/changelog/3099.bugfix.rst diff --git a/docs/changelog/3099.bugfix.rst b/docs/changelog/3099.bugfix.rst new file mode 100644 index 000000000..abb1b7839 --- /dev/null +++ b/docs/changelog/3099.bugfix.rst @@ -0,0 +1 @@ +Fix regression introduced in ``4.9.0`` affecting hyphenated and combined environments. diff --git a/src/tox/session/env_select.py b/src/tox/session/env_select.py index 9b14a4ac4..3f7e9789a 100644 --- a/src/tox/session/env_select.py +++ b/src/tox/session/env_select.py @@ -4,7 +4,7 @@ import re from collections import Counter from dataclasses import dataclass -from itertools import chain +from itertools import chain, permutations from typing import TYPE_CHECKING, Dict, Iterable, Iterator, List, cast from tox.config.loader.str_convert import StrConvert @@ -155,10 +155,12 @@ def _collect_names(self) -> Iterator[tuple[Iterable[str], bool]]: cli_envs_not_in_config = set(self._cli_envs) - set(self._state.conf) if cli_envs_not_in_config: # allow cli_envs matching ".pkg" and starting with "py" to be implicitly created. - disallowed_cli_envs = [ - env for env in cli_envs_not_in_config if not env.startswith("py") and env not in (".pkg",) - ] - if disallowed_cli_envs: + disallowed_cli_envs = [env for env in cli_envs_not_in_config if not _is_valid_exception(env)] + + # allow disallowed cli envs that match hyphenated combinations + has_match = any(_find_env_match(cli_env, set(self._state.conf)) for cli_env in disallowed_cli_envs) + + if disallowed_cli_envs and not has_match: msg = f"provided environments not found in configuration file: {disallowed_cli_envs}" raise HandledError(msg) yield self._cli_envs, True @@ -389,6 +391,16 @@ def _mark_provision(self, on: bool, provision_tox_env: str) -> None: # noqa: FB self._provision = on, provision_tox_env +def _find_env_match(value: str, state_conf: set[str]) -> bool: + return any(value in conf.split("-") for conf in state_conf) or any( + value == "-".join(combo) for combo in permutations(state_conf, 2) + ) + + +def _is_valid_exception(env: str) -> bool: + return env.startswith("py") or env in (".pkg",) + + __all__ = [ "register_env_select_flags", "EnvSelector", diff --git a/tests/session/test_env_select.py b/tests/session/test_env_select.py index 13de0ea66..dc7b76e94 100644 --- a/tests/session/test_env_select.py +++ b/tests/session/test_env_select.py @@ -164,3 +164,40 @@ def test_allowed_implicit_cli_envs(env_name: str, tox_project: ToxProjectCreator outcome.assert_success() assert env_name in outcome.out assert not outcome.err + + +@pytest.mark.parametrize("env_name", ["a", "b", "a-b"]) +def test_matches_hyphenated_env(env_name: str, tox_project: ToxProjectCreator) -> None: + tox_ini = """ + [tox] + env_list=a-b + [testenv] + package=skip + commands_pre = + a: python -c 'print("a")' + b: python -c 'print("b")' + commands=python -c 'print("ok")' + """ + proj = tox_project({"tox.ini": tox_ini}) + outcome = proj.run("r", "-e", env_name) + outcome.assert_success() + assert env_name in outcome.out + assert not outcome.err + + +@pytest.mark.parametrize("env_name", ["3.9", "3.9-cov"]) +def test_matches_combined_env(env_name: str, tox_project: ToxProjectCreator) -> None: + tox_ini = """ + [tox] + env_list=3.9 + [testenv] + package=skip + commands = + !cov: python -c 'print("without cov")' + cov: python -c 'print("with cov")' + """ + proj = tox_project({"tox.ini": tox_ini}) + outcome = proj.run("r", "-e", env_name) + outcome.assert_success() + assert env_name in outcome.out + assert not outcome.err From ea87d63c21a93c94ca6cbaf21a7b3517c8b2ea84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 21 Aug 2023 08:57:36 -0700 Subject: [PATCH 2/2] PR Feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bernát Gábor --- docs/changelog/3099.bugfix.rst | 1 - docs/changelog/3099.feature.rst | 3 ++ src/tox/session/env_select.py | 66 ++++++++++++++++++++---------- tests/session/test_env_select.py | 69 ++++++++++++++++++++++++++------ 4 files changed, 104 insertions(+), 35 deletions(-) delete mode 100644 docs/changelog/3099.bugfix.rst create mode 100644 docs/changelog/3099.feature.rst diff --git a/docs/changelog/3099.bugfix.rst b/docs/changelog/3099.bugfix.rst deleted file mode 100644 index abb1b7839..000000000 --- a/docs/changelog/3099.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fix regression introduced in ``4.9.0`` affecting hyphenated and combined environments. diff --git a/docs/changelog/3099.feature.rst b/docs/changelog/3099.feature.rst new file mode 100644 index 000000000..26aa62a04 --- /dev/null +++ b/docs/changelog/3099.feature.rst @@ -0,0 +1,3 @@ +Change accepted environment name rule: must be made up of factors defined in configuration or match regex +``(pypy|py|cython|)((\d(\.\d+(\.\d+)?)?)|\d+)?``. If an environment name does not match this fail, and if a close match +found suggest that to the user. diff --git a/src/tox/session/env_select.py b/src/tox/session/env_select.py index 3f7e9789a..90f3dff74 100644 --- a/src/tox/session/env_select.py +++ b/src/tox/session/env_select.py @@ -4,7 +4,8 @@ import re from collections import Counter from dataclasses import dataclass -from itertools import chain, permutations +from difflib import get_close_matches +from itertools import chain from typing import TYPE_CHECKING, Dict, Iterable, Iterator, List, cast from tox.config.loader.str_convert import StrConvert @@ -117,6 +118,10 @@ class _ToxEnvInfo: package_skip: tuple[str, Skip] | None = None #: if set the creation of the packaging environment failed +_DYNAMIC_ENV_FACTORS = re.compile(r"(pypy|py|cython|)((\d(\.\d+(\.\d+)?)?)|\d+)?") +_PY_PRE_RELEASE_FACTOR = re.compile(r"alpha|beta|rc\.\d+") + + class EnvSelector: def __init__(self, state: State) -> None: # needs core to load the default tox environment list @@ -152,23 +157,50 @@ def _collect_names(self) -> Iterator[tuple[Iterable[str], bool]]: elif self._cli_envs.is_all: everything_active = True else: - cli_envs_not_in_config = set(self._cli_envs) - set(self._state.conf) - if cli_envs_not_in_config: - # allow cli_envs matching ".pkg" and starting with "py" to be implicitly created. - disallowed_cli_envs = [env for env in cli_envs_not_in_config if not _is_valid_exception(env)] - - # allow disallowed cli envs that match hyphenated combinations - has_match = any(_find_env_match(cli_env, set(self._state.conf)) for cli_env in disallowed_cli_envs) - - if disallowed_cli_envs and not has_match: - msg = f"provided environments not found in configuration file: {disallowed_cli_envs}" - raise HandledError(msg) + self._ensure_envs_valid() yield self._cli_envs, True yield self._state.conf, everything_active label_envs = dict.fromkeys(chain.from_iterable(self._state.conf.core["labels"].values())) if label_envs: yield label_envs.keys(), False + def _ensure_envs_valid(self) -> None: + valid_factors = set(chain.from_iterable(env.split("-") for env in self._state.conf)) + valid_factors.add(".pkg") # packaging factor + invalid_envs: dict[str, str | None] = {} + for env in self._cli_envs or []: + if env.startswith(".pkg_external"): # external package + continue + factors: dict[str, str | None] = {k: None for k in env.split("-")} + found_factors: set[str] = set() + for factor in factors: + if ( + _DYNAMIC_ENV_FACTORS.fullmatch(factor) + or _PY_PRE_RELEASE_FACTOR.fullmatch(factor) + or factor in valid_factors + ): + found_factors.add(factor) + else: + closest = get_close_matches(factor, valid_factors, n=1) + factors[factor] = closest[0] if closest else None + if set(factors) - found_factors: + invalid_envs[env] = ( + None + if any(i is None for i in factors.values()) + else "-".join(cast(Iterable[str], factors.values())) + ) + if invalid_envs: + msg = "provided environments not found in configuration file:\n" + first = True + for env, suggestion in invalid_envs.items(): + if not first: + msg += "\n" + first = False + msg += env + if suggestion: + msg += f" - did you mean {suggestion}?" + raise HandledError(msg) + def _env_name_to_active(self) -> dict[str, bool]: env_name_to_active_map = {} for a_collection, is_active in self._collect_names(): @@ -391,16 +423,6 @@ def _mark_provision(self, on: bool, provision_tox_env: str) -> None: # noqa: FB self._provision = on, provision_tox_env -def _find_env_match(value: str, state_conf: set[str]) -> bool: - return any(value in conf.split("-") for conf in state_conf) or any( - value == "-".join(combo) for combo in permutations(state_conf, 2) - ) - - -def _is_valid_exception(env: str) -> bool: - return env.startswith("py") or env in (".pkg",) - - __all__ = [ "register_env_select_flags", "EnvSelector", diff --git a/tests/session/test_env_select.py b/tests/session/test_env_select.py index dc7b76e94..2b4f6add6 100644 --- a/tests/session/test_env_select.py +++ b/tests/session/test_env_select.py @@ -1,11 +1,12 @@ from __future__ import annotations +import sys from typing import TYPE_CHECKING import pytest from tox.config.cli.parse import get_options -from tox.session.env_select import CliEnv, EnvSelector +from tox.session.env_select import _DYNAMIC_ENV_FACTORS, CliEnv, EnvSelector from tox.session.state import State if TYPE_CHECKING: @@ -150,13 +151,6 @@ def test_cli_env_can_be_specified_in_additional_environments(tox_project: ToxPro assert not outcome.err -def test_cli_env_not_in_tox_config_fails(tox_project: ToxProjectCreator) -> None: - proj = tox_project({"tox.ini": ""}) - outcome = proj.run("r", "-e", "does_not_exist") - outcome.assert_failed(code=-2) - assert "provided environments not found in configuration file: ['does_not_exist']" in outcome.out, outcome.out - - @pytest.mark.parametrize("env_name", ["py", "py310", ".pkg"]) def test_allowed_implicit_cli_envs(env_name: str, tox_project: ToxProjectCreator) -> None: proj = tox_project({"tox.ini": ""}) @@ -166,7 +160,7 @@ def test_allowed_implicit_cli_envs(env_name: str, tox_project: ToxProjectCreator assert not outcome.err -@pytest.mark.parametrize("env_name", ["a", "b", "a-b"]) +@pytest.mark.parametrize("env_name", ["a", "b", "a-b", "b-a"]) def test_matches_hyphenated_env(env_name: str, tox_project: ToxProjectCreator) -> None: tox_ini = """ [tox] @@ -185,11 +179,15 @@ def test_matches_hyphenated_env(env_name: str, tox_project: ToxProjectCreator) - assert not outcome.err -@pytest.mark.parametrize("env_name", ["3.9", "3.9-cov"]) +_MINOR = sys.version_info.minor + + +@pytest.mark.parametrize( + "env_name", + [f"3.{_MINOR}", f"3.{_MINOR}-cov", "3-cov", "3", f"3.{_MINOR}", f"py3{_MINOR}-cov", f"py3.{_MINOR}-cov"], +) def test_matches_combined_env(env_name: str, tox_project: ToxProjectCreator) -> None: tox_ini = """ - [tox] - env_list=3.9 [testenv] package=skip commands = @@ -201,3 +199,50 @@ def test_matches_combined_env(env_name: str, tox_project: ToxProjectCreator) -> outcome.assert_success() assert env_name in outcome.out assert not outcome.err + + +@pytest.mark.parametrize( + "env", + [ + "py", + "pypy", + "pypy3", + "pypy3.12", + "pypy312", + "py3", + "py3.12", + "py312", + "3", + "3.12", + "3.12.0", + ], +) +def test_dynamic_env_factors_match(env: str) -> None: + assert _DYNAMIC_ENV_FACTORS.fullmatch(env) + + +@pytest.mark.parametrize( + "env", + [ + "cy3", + "cov", + "py10.1", + ], +) +def test_dynamic_env_factors_not_match(env: str) -> None: + assert not _DYNAMIC_ENV_FACTORS.fullmatch(env) + + +def test_suggest_env(tox_project: ToxProjectCreator) -> None: + tox_ini = f"[testenv:release]\n[testenv:py3{_MINOR}]\n[testenv:alpha-py3{_MINOR}]\n" + proj = tox_project({"tox.ini": tox_ini}) + outcome = proj.run("r", "-e", f"releas,p3{_MINOR},magic,alph-p{_MINOR}") + outcome.assert_failed(code=-2) + + assert not outcome.err + msg = ( + "ROOT: HandledError| provided environments not found in configuration file:\n" + f"releas - did you mean release?\np3{_MINOR} - did you mean py3{_MINOR}?\nmagic\n" + f"alph-p{_MINOR} - did you mean alpha-py3{_MINOR}?\n" + ) + assert outcome.out == msg