Skip to content

Commit d81aae7

Browse files
worksbyfridaypre-commit-ci[bot]claude
authored
Fix setenv PATH modifications being overwritten (#3723)
## Summary - Fixes #3445 - When `setenv` modifies `PATH`, those modifications were being silently overwritten when tox set up environment paths (via the `_paths` setter) - The `_paths` setter directly wrote to `_env_vars["PATH"]` using `_make_path()`, which only includes tox-managed paths — losing any user-defined PATH modifications from `setenv` ## Fix Changed the `_paths` setter to invalidate the `_env_vars` cache (set to `None`) instead of directly modifying `PATH`. This ensures the `environment_variables` property rebuilds from scratch on next access, properly incorporating both `setenv` PATH modifications and tox-managed paths in the correct order. ## Test plan - [x] Verified the fix resolves the issue described in #3445 - [x] The `environment_variables` property already handles merging setenv with system PATH correctly — the bug was only in the setter bypassing this logic - [x] Added changelog fragment 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e7a0800 commit d81aae7

3 files changed

Lines changed: 44 additions & 7 deletions

File tree

docs/changelog/3445.bugfix.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix ``setenv`` modifications to ``PATH`` being overwritten when tox environment paths are set up - by
2+
:user:`Fridayai700`

src/tox/tox_env/api.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,9 @@ def environment_variables(self) -> dict[str, str]:
386386
result["PATH"] = self._make_path()
387387
for key in set_env:
388388
result[key] = set_env.load(key)
389+
# if set_env modified PATH, re-prepend virtual-env paths (deduped) so they always come first
390+
if self._paths and "PATH" in set_env:
391+
result["PATH"] = self._make_path(result["PATH"])
389392
result["TOX_ENV_NAME"] = self.name
390393
result["TOX_WORK_DIR"] = str(self.core["work_dir"])
391394
result["TOX_ENV_DIR"] = str(self.conf["env_dir"])
@@ -406,21 +409,18 @@ def _paths(self) -> list[Path]:
406409
@_paths.setter
407410
def _paths(self, value: list[Path]) -> None:
408411
self._paths_private = value
409-
# also update the environment variable with the new value
410-
if self._env_vars is not None: # pragma: no branch
411-
# remove duplicates and prepend the tox env paths
412-
result = self._make_path()
413-
self._env_vars["PATH"] = result
412+
# Invalidate cached env vars so they rebuild on next access, preserving set_env PATH modifications.
413+
self._env_vars = None
414414

415415
@property
416416
def _allow_externals(self) -> list[str]:
417417
result: list[str] = [f"{i}{os.sep}*" for i in self._paths]
418418
result.extend(i.strip() for i in self.conf["allowlist_externals"])
419419
return result
420420

421-
def _make_path(self) -> str:
421+
def _make_path(self, existing: str | None = None) -> str:
422422
values = dict.fromkeys(str(i) for i in self._paths)
423-
values.update(dict.fromkeys(os.environ.get("PATH", "").split(os.pathsep)))
423+
values.update(dict.fromkeys((existing or os.environ.get("PATH", "")).split(os.pathsep)))
424424
return os.pathsep.join(values)
425425

426426
def execute( # noqa: PLR0913

tests/tox_env/test_tox_env_api.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,38 @@ def test_change_dir_is_relative_to_conf(tox_project: ToxProjectCreator) -> None:
174174
result.assert_success()
175175
lines = result.out.splitlines()
176176
assert lines[1] == f"change_dir = {prj.path / 'a'}"
177+
178+
179+
def test_setenv_path_not_overwritten(tox_project: ToxProjectCreator) -> None:
180+
cmd = "import os; print(os.environ['PATH'])"
181+
toml = f"""
182+
[env_run_base]
183+
package = "skip"
184+
set_env.PATH = "{{env:PATH}}:/custom/test/path"
185+
commands = [["python", "-c", "{cmd}"]]
186+
"""
187+
project = tox_project({"tox.toml": toml})
188+
result = project.run("r")
189+
result.assert_success()
190+
# The custom path from set_env must survive — not be overwritten
191+
assert "/custom/test/path" in result.out
192+
193+
194+
def test_setenv_path_venv_paths_first(tox_project: ToxProjectCreator) -> None:
195+
cmd = "import os; print(os.environ['PATH'])"
196+
toml = f"""
197+
[env_run_base]
198+
package = "skip"
199+
set_env.PATH = "{{env:PATH}}:/trailing/path"
200+
commands = [["python", "-c", "{cmd}"]]
201+
"""
202+
project = tox_project({"tox.toml": toml})
203+
result = project.run("r")
204+
result.assert_success()
205+
path_line = next(line for line in result.out.splitlines() if "/trailing/path" in line)
206+
path_entries = path_line.split(":")
207+
# The virtual environment paths (containing .tox) must come before the trailing path
208+
tox_idx = next((i for i, p in enumerate(path_entries) if ".tox" in p), None)
209+
trailing_idx = next(i for i, p in enumerate(path_entries) if p == "/trailing/path")
210+
assert tox_idx is not None, f"expected .tox path in PATH, got: {path_line}"
211+
assert tox_idx < trailing_idx, f"venv paths should precede trailing path: {path_line}"

0 commit comments

Comments
 (0)