Skip to content

Commit a438b58

Browse files
🐛 fix(env): suggest normalized env name for dotted Python versions (#3888)
Users typing `tox -e py3.10-lint` when their envlist defined `py310-lint` saw the command silently fall back to the base `[testenv]` configuration, running the wrong environment without warning or error. This became increasingly problematic with Python 3.10+ where dotted version notation (py3.10, py3.11) is common, creating easy-to-miss typos that produce confusing results. Python version factors can be written with or without dots: `py3.10` vs `py310`, `3.10` vs `310`. The validation logic treated dotted versions as valid dynamic factors even when a normalized equivalent existed in the config. 🔍 Since `py3.10` matched the Python version regex and `lint` was a valid factor, `py3.10-lint` passed validation and created an ad-hoc environment using `[testenv]` instead of the intended `[testenv:py310-lint]`. The fix normalizes environment names by removing dots from Python version factors, then checks if the normalized name exists in known environments. When found, tox errors with `py3.10-lint - did you mean py310-lint?` instead of silently proceeding. ✨ If the normalized name doesn't exist (e.g., `py3.15` when only `py310` and `py311` are defined), the dotted version is allowed as an ad-hoc environment, preserving the flexibility of tox's dynamic environment creation. This catches typos while maintaining backward compatibility for intentional ad-hoc environments. Fixes #3877 --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent db1362d commit a438b58

File tree

4 files changed

+120
-10
lines changed

4 files changed

+120
-10
lines changed

docs/changelog/3877.bugfix.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Detect and suggest normalized environment names when users specify dotted Python versions (e.g., ``py3.10-lint``) that
2+
match existing environments with compact notation (e.g., ``py310-lint``), preventing silent fallback to base
3+
``[testenv]`` configuration - by :user:`gaborbernat`.

docs/how-to/usage.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,11 @@ For example, given:
276276
Running ``tox -e unt`` or ``tox -e unti`` would succeed without running any tests. An exception is made for environments
277277
that look like Python version specifiers -- ``tox -e 3.13`` or ``tox -e py313`` would still work as intended.
278278

279+
Note that Python versions can be written with or without dots (``py3.10`` vs ``py310``). If you define ``py310-lint`` in
280+
your configuration and accidentally run ``tox -e py3.10-lint``, tox will detect the mismatch and suggest the correct
281+
environment name with a ``did you mean py310-lint?`` message rather than silently falling back to the base test
282+
environment.
283+
279284
.. _platform-specification:
280285

281286
**************************************

src/tox/session/env_select.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,10 @@ def _ensure_envs_valid(self) -> None:
294294
for env in self._cli_envs or []:
295295
if env.startswith(".pkg_external") or env in known_envs:
296296
continue
297+
normalized_env = self._normalize_env_name(env)
298+
if normalized_env != env and normalized_env in known_envs:
299+
invalid_envs[env] = normalized_env
300+
continue
297301
factors: dict[str, str | None] = dict.fromkeys(env.split("-"))
298302
found_factors: set[str] = set()
299303
for factor in factors:
@@ -314,16 +318,31 @@ def _ensure_envs_valid(self) -> None:
314318
else "-".join(cast("Iterable[str]", factors.values()))
315319
)
316320
if invalid_envs:
317-
msg = "provided environments not found in configuration file:\n"
318-
first = True
319-
for env, suggestion in invalid_envs.items():
320-
if not first:
321-
msg += "\n"
322-
first = False
323-
msg += env
324-
if suggestion:
325-
msg += f" - did you mean {suggestion}?"
326-
raise HandledError(msg)
321+
self._raise_invalid_envs(invalid_envs)
322+
323+
@staticmethod
324+
def _normalize_env_name(env_name: str) -> str:
325+
factors = env_name.split("-")
326+
normalized_factors = []
327+
for factor in factors:
328+
if _DYNAMIC_ENV_FACTORS.fullmatch(factor):
329+
normalized_factors.append(factor.replace(".", ""))
330+
else:
331+
normalized_factors.append(factor)
332+
return "-".join(normalized_factors)
333+
334+
@staticmethod
335+
def _raise_invalid_envs(invalid_envs: dict[str, str | None]) -> None:
336+
msg = "provided environments not found in configuration file:\n"
337+
first = True
338+
for env, suggestion in invalid_envs.items():
339+
if not first:
340+
msg += "\n"
341+
first = False
342+
msg += env
343+
if suggestion:
344+
msg += f" - did you mean {suggestion}?"
345+
raise HandledError(msg)
327346

328347
def _env_name_to_active(self) -> dict[str, bool]:
329348
env_name_to_active_map = {}

tests/session/test_env_select.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,3 +487,86 @@ def test_multiple_unavailable_runners_implicit(tox_project: ToxProjectCreator) -
487487
assert "available: OK" in outcome.out
488488
assert "unavailable1: NOT AVAILABLE" in outcome.out
489489
assert "unavailable2: NOT AVAILABLE" in outcome.out
490+
491+
492+
@pytest.mark.parametrize(
493+
("env_list", "env_sections", "cli_env", "expected_suggestion"),
494+
[
495+
pytest.param(
496+
'["py310-lint", "py310-test", "py311-lint", "py311-test"]',
497+
['[tool.tox.env."py310-lint"]', '[tool.tox.env."py311-lint"]'],
498+
"py3.10-lint",
499+
"py310-lint",
500+
id="dotted_py_version_with_factor",
501+
),
502+
pytest.param(
503+
'["py310", "py311"]',
504+
['[tool.tox.env."py310-cov"]', '[tool.tox.env."py311-cov"]'],
505+
"py3.10-cov",
506+
"py310-cov",
507+
id="dotted_py_version_single",
508+
),
509+
pytest.param(
510+
'["310", "311"]',
511+
['[tool.tox.env."310-lint"]', '[tool.tox.env."311-lint"]'],
512+
"3.10-lint",
513+
"310-lint",
514+
id="explicit_version_format",
515+
),
516+
],
517+
)
518+
def test_dotted_env_name_suggests_normalized(
519+
tox_project: ToxProjectCreator, env_list: str, env_sections: list[str], cli_env: str, expected_suggestion: str
520+
) -> None:
521+
sections_text = "\n ".join(f'{sec}\n commands = [["echo", "specialized"]]' for sec in env_sections)
522+
toml = f"""
523+
[tool.tox]
524+
env_list = {env_list}
525+
526+
[tool.tox.env_run_base]
527+
package = "skip"
528+
commands = [["echo", "testenv"]]
529+
530+
{sections_text}
531+
"""
532+
proj = tox_project({"pyproject.toml": toml})
533+
outcome = proj.run("r", "-e", cli_env)
534+
outcome.assert_failed(code=-2)
535+
assert f"{cli_env} - did you mean {expected_suggestion}?" in outcome.out
536+
537+
538+
def test_dotted_env_no_normalized_match_allows_adhoc(tox_project: ToxProjectCreator) -> None:
539+
toml = """
540+
[tool.tox]
541+
env_list = ["lint"]
542+
543+
[tool.tox.env_run_base]
544+
package = "skip"
545+
commands = [["python", "-c", "print('adhoc')"]]
546+
"""
547+
proj = tox_project({"pyproject.toml": toml})
548+
outcome = proj.run("r", "-e", f"py{sys.version_info[0]}.{sys.version_info[1]}-lint")
549+
outcome.assert_success()
550+
assert "adhoc" in outcome.out
551+
552+
553+
def test_dotted_env_multiple_suggestions(tox_project: ToxProjectCreator) -> None:
554+
toml = """
555+
[tool.tox]
556+
env_list = ["py310-lint", "py311-cov"]
557+
558+
[tool.tox.env_run_base]
559+
package = "skip"
560+
commands = [["echo", "testenv"]]
561+
562+
[tool.tox.env."py310-lint"]
563+
commands = [["echo", "lint"]]
564+
565+
[tool.tox.env."py311-cov"]
566+
commands = [["echo", "coverage"]]
567+
"""
568+
proj = tox_project({"pyproject.toml": toml})
569+
outcome = proj.run("r", "-e", "py3.10-lint,py3.11-cov")
570+
outcome.assert_failed(code=-2)
571+
assert "py3.10-lint - did you mean py310-lint?" in outcome.out
572+
assert "py3.11-cov - did you mean py311-cov?" in outcome.out

0 commit comments

Comments
 (0)