diff --git a/docs/changelog/3445.bugfix.rst b/docs/changelog/3445.bugfix.rst new file mode 100644 index 0000000000..ad9329baac --- /dev/null +++ b/docs/changelog/3445.bugfix.rst @@ -0,0 +1,2 @@ +Fix ``setenv`` modifications to ``PATH`` being overwritten when tox environment paths are set up - by +:user:`Fridayai700` diff --git a/src/tox/tox_env/api.py b/src/tox/tox_env/api.py index a9cb3d8251..5a2c17f342 100644 --- a/src/tox/tox_env/api.py +++ b/src/tox/tox_env/api.py @@ -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"]) @@ -406,11 +409,8 @@ 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]: @@ -418,9 +418,9 @@ def _allow_externals(self) -> list[str]: 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 diff --git a/tests/tox_env/test_tox_env_api.py b/tests/tox_env/test_tox_env_api.py index b83d5a811d..b3b857434b 100644 --- a/tests/tox_env/test_tox_env_api.py +++ b/tests/tox_env/test_tox_env_api.py @@ -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}"