Skip to content

Commit e753137

Browse files
authored
Report a bad ini core value as a handled error (#4028)
A bad value in the ini `[tox]` core section exits with a raw traceback, while the same value in `pyproject.toml` is already reported properly. ``` $ printf '[tox]\nmin_version = notaversion\n' > tox.ini && tox l File ".../packaging/version.py", line 452, in __init__ raise InvalidVersion(f"Invalid version: {version!r}") packaging.version.InvalidVersion: Invalid version: 'notaversion' ``` The TOML path, same input, unchanged by this PR: ``` $ printf '[tool.tox]\nmin_version = "notaversion"\n' > pyproject.toml && tox l ROOT: HandledError| failed to load core.min_version: Invalid version: 'notaversion' ``` After: ``` ROOT: HandledError| failed to load tox.min_version: Invalid version: 'notaversion' ``` `requires = ===bad!!!` and `env_list = {py39,py310` (unbalanced brace) were tracebacks too, and are now handled the same way. ## The fix `IniLoader.build` called `self.to(...)` unguarded. `TomlLoader.build` already wraps the identical call and re-raises as `HandledError`, so this mirrors it, including letting `HandledError` and `Skip` through untouched. ## Why the guard is narrow The wrap only applies to the core section. My first attempt covered every section and broke 6 existing tests, because two paths deliberately let raw exceptions through: - testenv values, where `tox c` renders the exception inline as `# Exception: ...` (`test_config_bad_dict`, `test_config_bad_bool` and friends) - the CLI config file, loaded with `conf is None` Those are existing behaviour I did not want to change, so the condition is `conf is None or args.env_name is not None` to bypass. Core values are the case with no handling at all, because they are loaded before any guard is in place. ## Verification `test_ini_core_bad_value_is_handled_error` in `tests/config/source/test_discover.py`, parametrised over `min_version`, `requires` and `env_list`. It asserts no unhandled exception leaks, so it fails on the actual symptom rather than on a message string. Reverting only the guard condition to always bypass fails it as an assertion: ``` > assert leaked is None, f"unhandled {type(leaked).__name__}: {leaked}" E AssertionError: unhandled InvalidVersion: Invalid version: 'notaversion' E AssertionError: unhandled InvalidRequirement: Expected package name at the start of dependency specifier E AssertionError: unhandled ValueError: {py39 ``` `pytest tests/config/` is 6644 passed, 2 skipped. `tests/config/cli/test_argcomplete.py` is excluded because `argcomplete` is not installed here; it fails to collect identically on a clean checkout. *Disclosure: written with AI assistance (Claude Code). I reproduced the traceback and the TOML contrast at the CLI, confirmed the narrow guard is required by removing it and watching 6 tests break, and ran the mutation check myself.* Co-authored-by: VXNCXNX <VXNCXNX@users.noreply.github.com>
1 parent 79a45b4 commit e753137

3 files changed

Lines changed: 38 additions & 1 deletion

File tree

docs/changelog/4028.bugfix.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Report an invalid value in the ini ``[tox]`` core section (such as ``min_version``, ``requires`` or ``env_list``) as a
2+
handled error instead of an unhandled traceback, matching the existing TOML loader behavior - by :user:`VXNCXNX`

src/tox/config/loader/ini/__init__.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,19 @@ def replacer(raw_: str, args_: ConfigLoadArgs) -> str:
9595
return replaced
9696

9797
prepared = replacer(raw, args) if not delay_replace else raw
98-
converted = self.to(prepared, of_type, factory)
98+
if conf is None or args.env_name is not None:
99+
# the CLI config file and tox environments have their own handling for a bad value
100+
converted = self.to(prepared, of_type, factory)
101+
else:
102+
try:
103+
converted = self.to(prepared, of_type, factory)
104+
except (HandledError, Skip):
105+
raise
106+
except Exception as exception:
107+
# core values are loaded before any guard is in place, so mirror the TOML loader and report a bad
108+
# value as a handled error instead of leaking a traceback
109+
msg = f"failed to load {self.core_section.key}.{key}: {exception}"
110+
raise HandledError(msg) from exception
99111
if delay_replace:
100112
cast("SetEnv", converted).use_replacer(replacer, args) # delay_replace means of_type is SetEnv
101113
return converted

tests/config/source/test_discover.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,29 @@ def test_malformed_ini_in_dir_reports_error(tox_project: ToxProjectCreator) -> N
117117
assert "File contains no section headers" in outcome.out
118118

119119

120+
@pytest.mark.parametrize(
121+
("core_value", "message"),
122+
[
123+
pytest.param("min_version = notaversion", "min_version: Invalid version", id="min_version"),
124+
pytest.param("requires = ===bad!!!", "requires: Expected package name", id="requires"),
125+
pytest.param("env_list = {py39,py310", "env_list: {py39", id="env_list"),
126+
],
127+
)
128+
def test_bad_ini_core_value_reports_error(tox_project: ToxProjectCreator, core_value: str, message: str) -> None:
129+
"""A bad value in the ini core section should be a handled error rather than an unhandled traceback."""
130+
project = tox_project({"tox.ini": f"[tox]\n{core_value}\n"})
131+
outcome, leaked = None, None
132+
try:
133+
outcome = project.run("l")
134+
except Exception as exception: # ruff:ignore[blind-except] # a leaked traceback is the bug under test
135+
leaked = exception
136+
assert leaked is None, f"unhandled {type(leaked).__name__}: {leaked}"
137+
assert outcome is not None
138+
outcome.assert_failed()
139+
assert "failed to load tox." in outcome.out
140+
assert message in outcome.out
141+
142+
120143
def test_toml_native_preferred_over_legacy_tox_ini(tox_project: ToxProjectCreator) -> None:
121144
"""When pyproject.toml has both legacy_tox_ini and native TOML config, native TOML should win."""
122145
pyproject = """\

0 commit comments

Comments
 (0)