Skip to content

Commit 59e984f

Browse files
authored
Report an empty TOML command value as a handled error (#4041)
`install_command = []` in TOML raises `AttributeError: 'NoneType' object has no attribute 'args'` with a traceback and exit code 2. `list_dependencies_command = []` does the same. The ini spelling `install_command=` reports `unable to determine pip install command: attempting to parse '' into a command failed` and exits 1. ## The cause `Convert.to_command` is declared `Command | None`, and the two loaders disagree on what `None` means. `StrConvert.to_command("")` raises `ValueError`; `TomlLoader.to_command([])` returns `None`, which is what lets #3388 drop empty entries inside `commands`. For a scalar `of_type=Command` that `None` is cast straight through to the caller, which then dereferences it. ## The fix `to_command` now returns a `Command` or raises, in every loader. The TOML rule that an empty entry inside a list of commands is dropped moves into `TomlLoader.to_list`, which is where `StrConvert.to_list` already drops empty tokens. `convert.py` loses its `Command`-shaped branch in the shared list path, so the format-specific rule no longer leaks into the format-agnostic layer. Making the abstract return type `Command` also surfaced that `StrConvert.to_command` never returned `None` in the first place - `ty` rejected the override until the annotation was corrected. Both formats now stop before the dereference: ``` $ printf '[env.py]\ninstall_command = []\n' > tox.toml && tox r -e py py: failed to load py.install_command: attempting to parse [] into a command failed ``` Drive-by: `TomlLoader.to_set` was a byte-for-byte copy of `to_list` and now delegates to it. ## Verification `test_toml_loader_command_empty_nok` covers the scalar rejection and `test_toml_loader_command_list_drops_empty` pins the #3388 behaviour; removing either half of the change fails the matching test. `test_pip_toml_empty_command_error` is parametrised over `install_command` and `list_dependencies_command`, the two scalar `Command` settings. Full suite passes, as do `tox -e type` (ty, mypy, pyrefly) and `tox -e fix`. - [x] ran the linter to address style issues (`tox -e fix`) - [x] wrote descriptive pull request text - [x] ensured there are test(s) validating the fix - [x] added news fragment in `docs/changelog` folder - [ ] updated/extended the documentation Co-authored-by: Dylan Pulver <dylanpulver@users.noreply.github.com>
1 parent fcb513e commit 59e984f

7 files changed

Lines changed: 47 additions & 13 deletions

File tree

docs/changelog/4041.bugfix.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Report an empty ``install_command`` or ``list_dependencies_command`` in a TOML configuration as a handled error instead
2+
of an unhandled traceback - by :user:`dylanpulver`

src/tox/config/loader/convert.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,6 @@ def _to_typing(self, raw: T, of_type: type[V] | UnionType, factory: Factory[V])
6363
if origin in {list, list}:
6464
entry_type = type_args[0]
6565
result = [self.to(i, entry_type, factory) for i in self.to_list(raw, entry_type)]
66-
if isclass(entry_type) and issubclass(entry_type, Command):
67-
result = [i for i in result if i is not None]
6866
elif origin in {set, set}:
6967
entry_type = type_args[0]
7068
result = {self.to(i, entry_type, factory) for i in self.to_set(raw, entry_type)}
@@ -180,7 +178,7 @@ def to_path(value: T) -> Path:
180178

181179
@staticmethod
182180
@abstractmethod
183-
def to_command(value: T) -> Command | None:
181+
def to_command(value: T) -> Command:
184182
"""Convert to a command to execute.
185183
186184
:param value: the value to convert

src/tox/config/loader/memory.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def to_path(value: Any) -> Path:
5353
return Path(value)
5454

5555
@staticmethod
56-
def to_command(value: Any) -> Command | None:
56+
def to_command(value: Any) -> Command:
5757
if isinstance(value, Command):
5858
return value
5959
if isinstance(value, str):

src/tox/config/loader/str_convert.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def _win32_process_path_backslash(value: str, escape: str, special_chars: str) -
7474
return "".join(result)
7575

7676
@staticmethod
77-
def to_command(value: str) -> Command | None:
77+
def to_command(value: str) -> Command:
7878
"""At this point, ``value`` has already been substituted out, and all punctuation / escapes are final.
7979
8080
Value will typically be stripped of whitespace when coming from an ini file.

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

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,15 @@ def to_bool(value: TomlTypes) -> bool:
105105

106106
@staticmethod
107107
def to_list(value: TomlTypes, of_type: type[_T]) -> Iterator[_T]:
108-
result = validate(value, cast("type[list[Any]]", GenericAlias(list, (of_type,))))
109-
return iter(cast("list[_T]", result))
108+
result = cast("list[_T]", validate(value, cast("type[list[Any]]", GenericAlias(list, (of_type,)))))
109+
if inspect.isclass(of_type) and issubclass(of_type, Command):
110+
# a generated command list leaves gaps rather than compacting itself, so drop them here (see #3388)
111+
return iter([i for i in result if i])
112+
return iter(result)
110113

111114
@staticmethod
112115
def to_set(value: TomlTypes, of_type: type[_T]) -> Iterator[_T]:
113-
result = validate(value, cast("type[list[Any]]", GenericAlias(list, (of_type,))))
114-
return iter(cast("list[_T]", result))
116+
return TomlLoader.to_list(value, of_type)
115117

116118
@staticmethod
117119
def to_dict(value: TomlTypes, of_type: tuple[type[_T], type[_V]]) -> Iterator[tuple[_T, _V]]:
@@ -123,10 +125,11 @@ def to_path(value: TomlTypes) -> Path:
123125
return Path(TomlLoader.to_str(value))
124126

125127
@staticmethod
126-
def to_command(value: TomlTypes) -> Command | None:
127-
if value:
128-
return Command(args=cast("list[str]", value)) # validated during load in _ensure_type_correct
129-
return None
128+
def to_command(value: TomlTypes) -> Command:
129+
if not value:
130+
msg = f"attempting to parse {value!r} into a command failed"
131+
raise ValueError(msg)
132+
return Command(args=cast("list[str]", value)) # validated during load in _ensure_type_correct
130133

131134
@staticmethod
132135
def to_env_list(value: TomlTypes) -> EnvList:

tests/config/loader/test_toml_loader.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,16 @@ def test_toml_loader_command_nok() -> None:
122122
perform_load([["a", 1]], list[Command])
123123

124124

125+
def test_toml_loader_command_list_drops_empty() -> None:
126+
commands = perform_load([[], ["c"]], list[Command])
127+
assert [i.args for i in commands] == [["c"]]
128+
129+
130+
def test_toml_loader_command_empty_nok() -> None:
131+
with pytest.raises(HandledError, match=_PREFIX + r"attempting to parse \[\] into a command failed"):
132+
perform_load([], Command)
133+
134+
125135
def test_toml_loader_env_list_ok() -> None:
126136
res = perform_load(["a", "b"], EnvList)
127137
assert isinstance(res, EnvList)

tests/tox_env/python/pip/test_pip_install.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
import pytest
99
from packaging.requirements import Requirement
1010

11+
from tox.report import HandledError
1112
from tox.tox_env.errors import Fail
1213

1314
if TYPE_CHECKING:
15+
from collections.abc import Callable
1416
from pathlib import Path
1517

1618
from tox.pytest import CaptureFixture, SubRequest, ToxProject, ToxProjectCreator
@@ -50,6 +52,25 @@ def test_pip_install_empty_command_error(tox_project: ToxProjectCreator) -> None
5052
pip.install([Requirement("name")], "section", "type")
5153

5254

55+
@pytest.mark.parametrize(
56+
("key", "use"),
57+
[
58+
pytest.param(
59+
"install_command",
60+
lambda pip: pip.install([Requirement("name")], "section", "type"),
61+
id="install_command",
62+
),
63+
pytest.param("list_dependencies_command", lambda pip: pip.installed(), id="list_dependencies_command"),
64+
],
65+
)
66+
def test_pip_toml_empty_command_error(tox_project: ToxProjectCreator, key: str, use: Callable[[Any], object]) -> None:
67+
proj = tox_project({"tox.toml": f"[env.py]\n{key} = []"})
68+
pip = proj.run("l").state.envs["py"].installer
69+
70+
with pytest.raises(HandledError, match=rf"failed to load py\.{key}: attempting to parse \[\] into a command"):
71+
use(pip)
72+
73+
5374
def test_pip_install_flags_only_error(tox_project: ToxProjectCreator) -> None:
5475
proj = tox_project({"tox.ini": "[testenv:py]\ndeps=-i a"})
5576
result = proj.run("r")

0 commit comments

Comments
 (0)