Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/changelog/3445.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Fix ``setenv`` modifications to ``PATH`` being overwritten when tox environment paths are set up - by
:user:`Fridayai700`
14 changes: 7 additions & 7 deletions src/tox/tox_env/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,9 @@ def environment_variables(self) -> dict[str, str]:
result["PATH"] = self._make_path()
for key in set_env:
result[key] = set_env.load(key)
# if set_env modified PATH, re-prepend virtual-env paths (deduped) so they always come first
if self._paths and "PATH" in set_env:
result["PATH"] = self._make_path(result["PATH"])
result["TOX_ENV_NAME"] = self.name
result["TOX_WORK_DIR"] = str(self.core["work_dir"])
result["TOX_ENV_DIR"] = str(self.conf["env_dir"])
Expand All @@ -406,21 +409,18 @@ def _paths(self) -> list[Path]:
@_paths.setter
def _paths(self, value: list[Path]) -> None:
self._paths_private = value
# also update the environment variable with the new value
if self._env_vars is not None: # pragma: no branch
# remove duplicates and prepend the tox env paths
result = self._make_path()
self._env_vars["PATH"] = result
# Invalidate cached env vars so they rebuild on next access, preserving set_env PATH modifications.
self._env_vars = None

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

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

def execute( # noqa: PLR0913
Expand Down
35 changes: 35 additions & 0 deletions tests/tox_env/test_tox_env_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,38 @@ def test_change_dir_is_relative_to_conf(tox_project: ToxProjectCreator) -> None:
result.assert_success()
lines = result.out.splitlines()
assert lines[1] == f"change_dir = {prj.path / 'a'}"


def test_setenv_path_not_overwritten(tox_project: ToxProjectCreator) -> None:
cmd = "import os; print(os.environ['PATH'])"
toml = f"""
[env_run_base]
package = "skip"
set_env.PATH = "{{env:PATH}}:/custom/test/path"
commands = [["python", "-c", "{cmd}"]]
"""
project = tox_project({"tox.toml": toml})
result = project.run("r")
result.assert_success()
# The custom path from set_env must survive — not be overwritten
assert "/custom/test/path" in result.out


def test_setenv_path_venv_paths_first(tox_project: ToxProjectCreator) -> None:
cmd = "import os; print(os.environ['PATH'])"
toml = f"""
[env_run_base]
package = "skip"
set_env.PATH = "{{env:PATH}}:/trailing/path"
commands = [["python", "-c", "{cmd}"]]
"""
project = tox_project({"tox.toml": toml})
result = project.run("r")
result.assert_success()
path_line = next(line for line in result.out.splitlines() if "/trailing/path" in line)
path_entries = path_line.split(":")
# The virtual environment paths (containing .tox) must come before the trailing path
tox_idx = next((i for i, p in enumerate(path_entries) if ".tox" in p), None)
trailing_idx = next(i for i, p in enumerate(path_entries) if p == "/trailing/path")
assert tox_idx is not None, f"expected .tox path in PATH, got: {path_line}"
assert tox_idx < trailing_idx, f"venv paths should precede trailing path: {path_line}"