Skip to content

Commit e84002a

Browse files
worksbyfridayclaudepre-commit-ci[bot]
authored
Fix install_command ignored from TOML config (#3724)
## Summary Fixes #3574 — custom `install_command` was silently ignored when specified in `tox.toml` or `pyproject.toml`, always falling back to the default pip install command. ### Root cause Two bugs conspired to produce this behavior: 1. **`get_base_sections` placed base sections under the `env` namespace.** It used `test_env(b)` which wraps the section name under `env.*`, but `env_run_base` and `env_pkg_base` are top-level sections — `[env_run_base]` in `tox.toml` and `[tool.tox.env_run_base]` in `pyproject.toml`. This caused the loader to navigate to `env.env_run_base` (which doesn't exist) and return `None`, so no settings from `env_run_base` were applied. 2. **The TOML replacement engine raised on unknown `{...}` placeholders.** When `Unroll` processed strings like `{packages}` inside an `install_command` array, `TomlReplaceLoader` tried to resolve `packages` as a config key, failed with `KeyError`, and raised — causing the config loader to fall through to the default. The INI loader's equivalent (`ReplaceReferenceIni`) returns `None` for unresolvable references (keeping the original text), matching the documented contract of `ReplaceReference.__call__`. ### Changes - **`toml_pyproject.py`**: `get_base_sections` now constructs sections with the core prefix (or `None`), correctly placing `env_run_base`/`env_pkg_base` at the top level rather than under `env`. - **`_replace.py`**: `TomlReplaceLoader.__call__` returns `None` (instead of raising) when a `KeyError` occurs with no default, consistent with the INI loader and the `ReplaceReference` protocol. - **Test**: Parameterized test verifying `install_command` is respected from both `tox.toml` and `pyproject.toml`. - **Changelog**: Added `docs/changelog/3574.bugfix.rst`. ### Test plan - [x] New parameterized test (`tox.toml` and `pyproject.toml` variants) - [x] All 27 show_config tests pass - [x] All 143 config/loader/source tests pass - [x] All 49 pip install tests pass --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 00f79b9 commit e84002a

4 files changed

Lines changed: 51 additions & 19 deletions

File tree

docs/changelog/3574.bugfix.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix custom ``install_command`` being ignored when specified in TOML configuration (``tox.toml``/``pyproject.toml``) - by
2+
:user:`Fridayai700`.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ def __call__(self, value: str, conf_args: ConfigLoadArgs) -> str | None:
123123
default = settings["default"]
124124
if default is not None:
125125
return default
126+
return None # keep original text, consistent with ini loader behavior
126127
raise exception
127128
return value
128129

src/tox/config/source/toml_pyproject.py

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -55,26 +55,16 @@ def run_env_base(cls) -> str:
5555

5656
@property
5757
def keys(self) -> Iterable[str]:
58-
# Build keys from prefix and name components directly, rather than
59-
# splitting the joined key on SEP. This preserves dots that are part
60-
# of the name (e.g. environment name "py3.11") instead of treating
61-
# them as path separators.
62-
prefix = self._prefix
63-
name = self._name
58+
# Build keys from prefix + name directly, preserving dots in names (e.g. env "py3.11").
59+
prefix, name = self._prefix, self._name
6460
if prefix is None and not name:
6561
return []
66-
prefix_parts: list[str] = prefix.split(self.SEP) if prefix else []
67-
# Strip the global PREFIX (e.g. ("tool", "tox")) from the front
68-
if (
69-
self.PREFIX
70-
and len(prefix_parts) >= len(self.PREFIX)
71-
and tuple(prefix_parts[: len(self.PREFIX)]) == self.PREFIX
72-
):
73-
prefix_parts = prefix_parts[len(self.PREFIX) :]
74-
result = prefix_parts
62+
parts: list[str] = prefix.split(self.SEP) if prefix else []
63+
if self.PREFIX and len(parts) >= len(self.PREFIX) and tuple(parts[: len(self.PREFIX)]) == self.PREFIX:
64+
parts = parts[len(self.PREFIX) :] # strip global PREFIX (e.g. ("tool", "tox"))
7565
if name:
76-
result.append(name)
77-
return result
66+
parts.append(name)
67+
return parts
7868

7969

8070
class TomlPyProjectSection(TomlSection):
@@ -128,7 +118,7 @@ def get_loader(self, section: Section, override_map: OverrideMap) -> Loader[Any]
128118

129119
def envs(self, core_conf: CoreConfigSet) -> Iterator[str]:
130120
yield from core_conf["env_list"]
131-
yield from [i.name for i in self.sections()]
121+
yield from [section.name for section in self.sections()]
132122

133123
def sections(self) -> Iterator[Section]:
134124
for env_name in self._our_content.get(self._Section.ENV, {}):
@@ -138,7 +128,10 @@ def sections(self) -> Iterator[Section]:
138128
yield self._Section.test_env(env_name)
139129

140130
def get_base_sections(self, base: list[str], in_section: Section) -> Iterator[Section]: # noqa: ARG002
141-
yield from [self._Section.test_env(b) for b in base]
131+
core_prefix = self._Section.core_prefix()
132+
strip = f"{core_prefix}{self._Section.SEP}" if core_prefix else ""
133+
for entry in base:
134+
yield self._Section(prefix=core_prefix or None, name=entry.removeprefix(strip))
142135

143136
def get_tox_env_section(self, item: str) -> tuple[Section, list[str], list[str]]:
144137
return self._Section.test_env(item), [self._Section.run_env_base()], [self._Section.package_env_base()]

tests/session/cmd/test_show_config.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import pytest
1010

1111
from tox.config.types import Command
12+
from tox.execute.request import shell_cmd
1213

1314
if TYPE_CHECKING:
1415
from collections.abc import Callable
@@ -112,6 +113,41 @@ def test_show_config_empty_install_command_exception(tox_project: ToxProjectCrea
112113
assert txt in outcome.out
113114

114115

116+
@pytest.mark.parametrize(
117+
("filename", "content"),
118+
[
119+
pytest.param(
120+
"tox.toml",
121+
"""
122+
[env_run_base]
123+
package = "skip"
124+
install_command = ["echo", "CUSTOM", "{packages}"]
125+
commands = [["python", "-c", "pass"]]
126+
""",
127+
id="tox.toml",
128+
),
129+
pytest.param(
130+
"pyproject.toml",
131+
"""
132+
[tool.tox]
133+
env_list = ["py"]
134+
[tool.tox.env_run_base]
135+
package = "skip"
136+
install_command = ["echo", "CUSTOM", "{packages}"]
137+
commands = [["python", "-c", "pass"]]
138+
""",
139+
id="pyproject.toml",
140+
),
141+
],
142+
)
143+
def test_show_config_install_command_toml(tox_project: ToxProjectCreator, filename: str, content: str) -> None:
144+
project = tox_project({filename: content})
145+
outcome = project.run("c", "-k", "install_command")
146+
outcome.assert_success()
147+
expected_cmd = shell_cmd(["echo", "CUSTOM", "{packages}"])
148+
assert f"install_command = {expected_cmd}" in outcome.out
149+
150+
115151
def test_show_config_invalid_python_exit_code(tox_project: ToxProjectCreator) -> None:
116152
project = tox_project(
117153
{

0 commit comments

Comments
 (0)