Skip to content

Commit 1024592

Browse files
Accept environments with defined factors or of python selector form - suggest closest (#3099)
Co-authored-by: Bernát Gábor <bgabor8@bloomberg.net>
1 parent 6b8e83a commit 1024592

File tree

3 files changed

+136
-17
lines changed

3 files changed

+136
-17
lines changed

docs/changelog/3099.feature.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Change accepted environment name rule: must be made up of factors defined in configuration or match regex
2+
``(pypy|py|cython|)((\d(\.\d+(\.\d+)?)?)|\d+)?``. If an environment name does not match this fail, and if a close match
3+
found suggest that to the user.

src/tox/session/env_select.py

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import re
55
from collections import Counter
66
from dataclasses import dataclass
7+
from difflib import get_close_matches
78
from itertools import chain
89
from typing import TYPE_CHECKING, Dict, Iterable, Iterator, List, cast
910

@@ -117,6 +118,10 @@ class _ToxEnvInfo:
117118
package_skip: tuple[str, Skip] | None = None #: if set the creation of the packaging environment failed
118119

119120

121+
_DYNAMIC_ENV_FACTORS = re.compile(r"(pypy|py|cython|)((\d(\.\d+(\.\d+)?)?)|\d+)?")
122+
_PY_PRE_RELEASE_FACTOR = re.compile(r"alpha|beta|rc\.\d+")
123+
124+
120125
class EnvSelector:
121126
def __init__(self, state: State) -> None:
122127
# needs core to load the default tox environment list
@@ -152,21 +157,50 @@ def _collect_names(self) -> Iterator[tuple[Iterable[str], bool]]:
152157
elif self._cli_envs.is_all:
153158
everything_active = True
154159
else:
155-
cli_envs_not_in_config = set(self._cli_envs) - set(self._state.conf)
156-
if cli_envs_not_in_config:
157-
# allow cli_envs matching ".pkg" and starting with "py" to be implicitly created.
158-
disallowed_cli_envs = [
159-
env for env in cli_envs_not_in_config if not env.startswith("py") and env not in (".pkg",)
160-
]
161-
if disallowed_cli_envs:
162-
msg = f"provided environments not found in configuration file: {disallowed_cli_envs}"
163-
raise HandledError(msg)
160+
self._ensure_envs_valid()
164161
yield self._cli_envs, True
165162
yield self._state.conf, everything_active
166163
label_envs = dict.fromkeys(chain.from_iterable(self._state.conf.core["labels"].values()))
167164
if label_envs:
168165
yield label_envs.keys(), False
169166

167+
def _ensure_envs_valid(self) -> None:
168+
valid_factors = set(chain.from_iterable(env.split("-") for env in self._state.conf))
169+
valid_factors.add(".pkg") # packaging factor
170+
invalid_envs: dict[str, str | None] = {}
171+
for env in self._cli_envs or []:
172+
if env.startswith(".pkg_external"): # external package
173+
continue
174+
factors: dict[str, str | None] = {k: None for k in env.split("-")}
175+
found_factors: set[str] = set()
176+
for factor in factors:
177+
if (
178+
_DYNAMIC_ENV_FACTORS.fullmatch(factor)
179+
or _PY_PRE_RELEASE_FACTOR.fullmatch(factor)
180+
or factor in valid_factors
181+
):
182+
found_factors.add(factor)
183+
else:
184+
closest = get_close_matches(factor, valid_factors, n=1)
185+
factors[factor] = closest[0] if closest else None
186+
if set(factors) - found_factors:
187+
invalid_envs[env] = (
188+
None
189+
if any(i is None for i in factors.values())
190+
else "-".join(cast(Iterable[str], factors.values()))
191+
)
192+
if invalid_envs:
193+
msg = "provided environments not found in configuration file:\n"
194+
first = True
195+
for env, suggestion in invalid_envs.items():
196+
if not first:
197+
msg += "\n"
198+
first = False
199+
msg += env
200+
if suggestion:
201+
msg += f" - did you mean {suggestion}?"
202+
raise HandledError(msg)
203+
170204
def _env_name_to_active(self) -> dict[str, bool]:
171205
env_name_to_active_map = {}
172206
for a_collection, is_active in self._collect_names():

tests/session/test_env_select.py

Lines changed: 90 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from __future__ import annotations
22

3+
import sys
34
from typing import TYPE_CHECKING
45

56
import pytest
67

78
from tox.config.cli.parse import get_options
8-
from tox.session.env_select import CliEnv, EnvSelector
9+
from tox.session.env_select import _DYNAMIC_ENV_FACTORS, CliEnv, EnvSelector
910
from tox.session.state import State
1011

1112
if TYPE_CHECKING:
@@ -150,17 +151,98 @@ def test_cli_env_can_be_specified_in_additional_environments(tox_project: ToxPro
150151
assert not outcome.err
151152

152153

153-
def test_cli_env_not_in_tox_config_fails(tox_project: ToxProjectCreator) -> None:
154-
proj = tox_project({"tox.ini": ""})
155-
outcome = proj.run("r", "-e", "does_not_exist")
156-
outcome.assert_failed(code=-2)
157-
assert "provided environments not found in configuration file: ['does_not_exist']" in outcome.out, outcome.out
158-
159-
160154
@pytest.mark.parametrize("env_name", ["py", "py310", ".pkg"])
161155
def test_allowed_implicit_cli_envs(env_name: str, tox_project: ToxProjectCreator) -> None:
162156
proj = tox_project({"tox.ini": ""})
163157
outcome = proj.run("r", "-e", env_name)
164158
outcome.assert_success()
165159
assert env_name in outcome.out
166160
assert not outcome.err
161+
162+
163+
@pytest.mark.parametrize("env_name", ["a", "b", "a-b", "b-a"])
164+
def test_matches_hyphenated_env(env_name: str, tox_project: ToxProjectCreator) -> None:
165+
tox_ini = """
166+
[tox]
167+
env_list=a-b
168+
[testenv]
169+
package=skip
170+
commands_pre =
171+
a: python -c 'print("a")'
172+
b: python -c 'print("b")'
173+
commands=python -c 'print("ok")'
174+
"""
175+
proj = tox_project({"tox.ini": tox_ini})
176+
outcome = proj.run("r", "-e", env_name)
177+
outcome.assert_success()
178+
assert env_name in outcome.out
179+
assert not outcome.err
180+
181+
182+
_MINOR = sys.version_info.minor
183+
184+
185+
@pytest.mark.parametrize(
186+
"env_name",
187+
[f"3.{_MINOR}", f"3.{_MINOR}-cov", "3-cov", "3", f"3.{_MINOR}", f"py3{_MINOR}-cov", f"py3.{_MINOR}-cov"],
188+
)
189+
def test_matches_combined_env(env_name: str, tox_project: ToxProjectCreator) -> None:
190+
tox_ini = """
191+
[testenv]
192+
package=skip
193+
commands =
194+
!cov: python -c 'print("without cov")'
195+
cov: python -c 'print("with cov")'
196+
"""
197+
proj = tox_project({"tox.ini": tox_ini})
198+
outcome = proj.run("r", "-e", env_name)
199+
outcome.assert_success()
200+
assert env_name in outcome.out
201+
assert not outcome.err
202+
203+
204+
@pytest.mark.parametrize(
205+
"env",
206+
[
207+
"py",
208+
"pypy",
209+
"pypy3",
210+
"pypy3.12",
211+
"pypy312",
212+
"py3",
213+
"py3.12",
214+
"py312",
215+
"3",
216+
"3.12",
217+
"3.12.0",
218+
],
219+
)
220+
def test_dynamic_env_factors_match(env: str) -> None:
221+
assert _DYNAMIC_ENV_FACTORS.fullmatch(env)
222+
223+
224+
@pytest.mark.parametrize(
225+
"env",
226+
[
227+
"cy3",
228+
"cov",
229+
"py10.1",
230+
],
231+
)
232+
def test_dynamic_env_factors_not_match(env: str) -> None:
233+
assert not _DYNAMIC_ENV_FACTORS.fullmatch(env)
234+
235+
236+
def test_suggest_env(tox_project: ToxProjectCreator) -> None:
237+
tox_ini = f"[testenv:release]\n[testenv:py3{_MINOR}]\n[testenv:alpha-py3{_MINOR}]\n"
238+
proj = tox_project({"tox.ini": tox_ini})
239+
outcome = proj.run("r", "-e", f"releas,p3{_MINOR},magic,alph-p{_MINOR}")
240+
outcome.assert_failed(code=-2)
241+
242+
assert not outcome.err
243+
msg = (
244+
"ROOT: HandledError| provided environments not found in configuration file:\n"
245+
f"releas - did you mean release?\np3{_MINOR} - did you mean py3{_MINOR}?\nmagic\n"
246+
f"alph-p{_MINOR} - did you mean alpha-py3{_MINOR}?\n"
247+
)
248+
assert outcome.out == msg

0 commit comments

Comments
 (0)