From 26d7a1c957c7651d80adc8dafc218c5b04d41dfc Mon Sep 17 00:00:00 2001 From: darrenhuai <60621295+darrenhuai@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:06:15 -0700 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20fix(batch):=20remember=20an?= =?UTF-8?q?=20undefined=20PATH=20so=20deactivate=20can=20restore=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When PATH is undefined at activation, for example in a build script that runs `set PATH=` first, activate.bat has nothing to save. `set "_OLD_VIRTUAL_PATH=%PATH%"` expands to an empty value, which cmd treats as deleting the variable, and deactivate.bat only restores PATH when _OLD_VIRTUAL_PATH is defined, so it left the environment's Scripts folder on PATH. The re-activation guard from #3245 checks the same variable, so a second activate.bat did not deactivate first. It saved the active PATH as the one to restore and added Scripts a second time, and PKG_CONFIG_PATH got a second entry the same way. activate.bat now sets _OLD_VIRTUAL_PATH_UNSET when PATH is undefined, and the guard deactivates when that marker is set too. deactivate.bat undefines PATH when it finds the marker and then clears it. --- docs/changelog/+batch-unset-path.bugfix.rst | 2 ++ src/virtualenv/activation/batch/activate.bat | 3 +++ .../activation/batch/deactivate.bat | 2 ++ tests/unit/activation/test_batch.py | 25 +++++++++++++++---- 4 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 docs/changelog/+batch-unset-path.bugfix.rst diff --git a/docs/changelog/+batch-unset-path.bugfix.rst b/docs/changelog/+batch-unset-path.bugfix.rst new file mode 100644 index 000000000..000e65e6a --- /dev/null +++ b/docs/changelog/+batch-unset-path.bugfix.rst @@ -0,0 +1,2 @@ +Undefine ``PATH`` again in ``deactivate.bat`` when it was undefined at activation, instead of leaving the environment's +``Scripts`` folder on it, and stop a second ``activate.bat`` from adding that folder twice. diff --git a/src/virtualenv/activation/batch/activate.bat b/src/virtualenv/activation/batch/activate.bat index 23bd6c81b..b050addfc 100644 --- a/src/virtualenv/activation/batch/activate.bat +++ b/src/virtualenv/activation/batch/activate.bat @@ -7,6 +7,7 @@ @REM Undo an activation that is still in effect, otherwise its values get saved as the ones to restore @if defined _OLD_VIRTUAL_PATH @call "__VIRTUAL_ENV_CALL__\__BIN_NAME__\deactivate.bat" +@if defined _OLD_VIRTUAL_PATH_UNSET @call "__VIRTUAL_ENV_CALL__\__BIN_NAME__\deactivate.bat" @set "VIRTUAL_ENV=__VIRTUAL_ENV__" @@ -52,6 +53,8 @@ @REM ) else ( @if defined _OLD_VIRTUAL_PATH @goto ENDIFVPATH2 @set "_OLD_VIRTUAL_PATH=%PATH%" + @REM An undefined PATH leaves _OLD_VIRTUAL_PATH undefined too, so record it for deactivate.bat + @if not defined PATH @set _OLD_VIRTUAL_PATH_UNSET=1 :ENDIFVPATH2 @set "PATH=%VIRTUAL_ENV%\__BIN_NAME__;%PATH%" diff --git a/src/virtualenv/activation/batch/deactivate.bat b/src/virtualenv/activation/batch/deactivate.bat index d486958ad..e7a566608 100644 --- a/src/virtualenv/activation/batch/deactivate.bat +++ b/src/virtualenv/activation/batch/deactivate.bat @@ -28,3 +28,5 @@ @set "PATH=%_OLD_VIRTUAL_PATH%" @set _OLD_VIRTUAL_PATH= :ENDIFVPATH +@if defined _OLD_VIRTUAL_PATH_UNSET @set PATH= +@set _OLD_VIRTUAL_PATH_UNSET= diff --git a/tests/unit/activation/test_batch.py b/tests/unit/activation/test_batch.py index b4da632c0..e7d30cf90 100644 --- a/tests/unit/activation/test_batch.py +++ b/tests/unit/activation/test_batch.py @@ -173,7 +173,17 @@ def test_batch_skips_changed_paths( pytest.param("@set PROMPT=", "$P$G", id="prompt-unset"), ], ) -def test_batch_path_round_trip(tmp_path: Path, name: str, delayed: str, set_prompt: str, prompt: str) -> None: +@pytest.mark.parametrize( + "path_unset", + [ + pytest.param(False, id="path-set"), + # a build script that starts from a clean environment can run `set PATH=` before activating + pytest.param(True, id="path-unset"), + ], +) +def test_batch_path_round_trip( # ruff:ignore[too-many-arguments] + tmp_path: Path, name: str, delayed: str, set_prompt: str, prompt: str, path_unset: bool +) -> None: creator: Final[Creator] = session_via_cli([str(tmp_path / name), "--no-seed"], setup_logging=False).creator creator.bin_dir.mkdir(parents=True) BatchActivator(VirtualEnvOptions(prompt="roundtrip$P")).generate(creator) @@ -181,9 +191,13 @@ def test_batch_path_round_trip(tmp_path: Path, name: str, delayed: str, set_prom f'@"{sys.executable}" -I -c "import json,os; ' "print(json.dumps({k:os.environ.get(k) for k in ('VIRTUAL_ENV','PATH','PROMPT')}))\"" ) + path: Final[str | None] = None if path_unset else os.environ["PATH"] (creator.bin_dir / "check.bat").write_text( - f"@echo off\n@set VIRTUAL_ENV=\n@set _OLD_VIRTUAL_PATH=\n@set _OLD_VIRTUAL_PROMPT=\n{set_prompt}\n" - f"@call activate.bat\n{snapshot}\n@call activate.bat\n{snapshot}\n@call deactivate.bat\n{snapshot}", + "@echo off\n@set VIRTUAL_ENV=\n@set _OLD_VIRTUAL_PATH=\n@set _OLD_VIRTUAL_PATH_UNSET=\n" + f"@set _OLD_VIRTUAL_PROMPT=\n{set_prompt}\n{'@set PATH=' if path_unset else ''}\n" + f"@call activate.bat\n{snapshot}\n@call activate.bat\n{snapshot}\n@call deactivate.bat\n{snapshot}\n" + # a later activation from another PATH must save that PATH, not state the deactivate left behind + f'@set "PATH=%SystemRoot%"\n@call activate.bat\n{snapshot}', encoding="utf-8", ) result: Final[subprocess.CompletedProcess[str]] = subprocess.run( @@ -197,13 +211,14 @@ def test_batch_path_round_trip(tmp_path: Path, name: str, delayed: str, set_prom ) active: Final[dict[str, str]] = { "VIRTUAL_ENV": str(creator.dest), - "PATH": f"{creator.bin_dir};{os.environ['PATH']}", + "PATH": f"{creator.bin_dir};{path or ''}", "PROMPT": f"(roundtrip$$P) {prompt}", } assert [json.loads(line) for line in result.stdout.splitlines()] == [ active, active, - {"VIRTUAL_ENV": None, "PATH": os.environ["PATH"], "PROMPT": prompt}, + {"VIRTUAL_ENV": None, "PATH": path, "PROMPT": prompt}, + {**active, "PATH": f"{creator.bin_dir};{os.environ['SYSTEMROOT']}"}, ] assert not result.stderr From c480405ed834d8756d629b5d45148c3534a9f75d Mon Sep 17 00:00:00 2001 From: darrenhuai <60621295+darrenhuai@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:53:23 -0700 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A7=AA=20test(batch):=20pass=20the=20?= =?UTF-8?q?PATH=20line=20as=20the=20parameter,=20like=20the=20prompt=20cas?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/activation/test_batch.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/unit/activation/test_batch.py b/tests/unit/activation/test_batch.py index e7d30cf90..7b4efbc7d 100644 --- a/tests/unit/activation/test_batch.py +++ b/tests/unit/activation/test_batch.py @@ -174,15 +174,15 @@ def test_batch_skips_changed_paths( ], ) @pytest.mark.parametrize( - "path_unset", + "unset_path", [ - pytest.param(False, id="path-set"), + pytest.param("", id="path-set"), # a build script that starts from a clean environment can run `set PATH=` before activating - pytest.param(True, id="path-unset"), + pytest.param("@set PATH=", id="path-unset"), ], ) def test_batch_path_round_trip( # ruff:ignore[too-many-arguments] - tmp_path: Path, name: str, delayed: str, set_prompt: str, prompt: str, path_unset: bool + tmp_path: Path, name: str, delayed: str, set_prompt: str, prompt: str, unset_path: str ) -> None: creator: Final[Creator] = session_via_cli([str(tmp_path / name), "--no-seed"], setup_logging=False).creator creator.bin_dir.mkdir(parents=True) @@ -191,12 +191,12 @@ def test_batch_path_round_trip( # ruff:ignore[too-many-arguments] f'@"{sys.executable}" -I -c "import json,os; ' "print(json.dumps({k:os.environ.get(k) for k in ('VIRTUAL_ENV','PATH','PROMPT')}))\"" ) - path: Final[str | None] = None if path_unset else os.environ["PATH"] + path: Final[str | None] = None if unset_path else os.environ["PATH"] (creator.bin_dir / "check.bat").write_text( "@echo off\n@set VIRTUAL_ENV=\n@set _OLD_VIRTUAL_PATH=\n@set _OLD_VIRTUAL_PATH_UNSET=\n" - f"@set _OLD_VIRTUAL_PROMPT=\n{set_prompt}\n{'@set PATH=' if path_unset else ''}\n" + f"@set _OLD_VIRTUAL_PROMPT=\n{set_prompt}\n{unset_path}\n" f"@call activate.bat\n{snapshot}\n@call activate.bat\n{snapshot}\n@call deactivate.bat\n{snapshot}\n" - # a later activation from another PATH must save that PATH, not state the deactivate left behind + # a later activation from another PATH must save that PATH, not state that deactivate left behind f'@set "PATH=%SystemRoot%"\n@call activate.bat\n{snapshot}', encoding="utf-8", ) From a951c7b37811759f6d3281092535af5f960f265f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 30 Oct 2026 11:23:52 -0700 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A7=AA=20test(batch):=20set=20PATH=20?= =?UTF-8?q?from=20a=20variable=20per=20case?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Taking PATH from PATH itself or from an undefined variable leaves it as is or undefines it, so the expected values read os.environ without a branch. --- tests/unit/activation/test_batch.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/unit/activation/test_batch.py b/tests/unit/activation/test_batch.py index 7b4efbc7d..8c365f6b7 100644 --- a/tests/unit/activation/test_batch.py +++ b/tests/unit/activation/test_batch.py @@ -174,15 +174,16 @@ def test_batch_skips_changed_paths( ], ) @pytest.mark.parametrize( - "unset_path", + "path_source", [ - pytest.param("", id="path-set"), - # a build script that starts from a clean environment can run `set PATH=` before activating - pytest.param("@set PATH=", id="path-unset"), + pytest.param("PATH", id="path-set"), + # a build script that starts from a clean environment can run `set PATH=` before activating; an undefined + # variable expands to nothing in a batch file, so taking PATH from it leaves PATH undefined + pytest.param("VIRTUALENV_UNDEFINED_PATH", id="path-unset"), ], ) def test_batch_path_round_trip( # ruff:ignore[too-many-arguments] - tmp_path: Path, name: str, delayed: str, set_prompt: str, prompt: str, unset_path: str + tmp_path: Path, name: str, delayed: str, set_prompt: str, prompt: str, path_source: str ) -> None: creator: Final[Creator] = session_via_cli([str(tmp_path / name), "--no-seed"], setup_logging=False).creator creator.bin_dir.mkdir(parents=True) @@ -191,10 +192,9 @@ def test_batch_path_round_trip( # ruff:ignore[too-many-arguments] f'@"{sys.executable}" -I -c "import json,os; ' "print(json.dumps({k:os.environ.get(k) for k in ('VIRTUAL_ENV','PATH','PROMPT')}))\"" ) - path: Final[str | None] = None if unset_path else os.environ["PATH"] (creator.bin_dir / "check.bat").write_text( "@echo off\n@set VIRTUAL_ENV=\n@set _OLD_VIRTUAL_PATH=\n@set _OLD_VIRTUAL_PATH_UNSET=\n" - f"@set _OLD_VIRTUAL_PROMPT=\n{set_prompt}\n{unset_path}\n" + f'@set _OLD_VIRTUAL_PROMPT=\n{set_prompt}\n@set "PATH=%{path_source}%"\n' f"@call activate.bat\n{snapshot}\n@call activate.bat\n{snapshot}\n@call deactivate.bat\n{snapshot}\n" # a later activation from another PATH must save that PATH, not state that deactivate left behind f'@set "PATH=%SystemRoot%"\n@call activate.bat\n{snapshot}', @@ -211,13 +211,13 @@ def test_batch_path_round_trip( # ruff:ignore[too-many-arguments] ) active: Final[dict[str, str]] = { "VIRTUAL_ENV": str(creator.dest), - "PATH": f"{creator.bin_dir};{path or ''}", + "PATH": f"{creator.bin_dir};{os.environ.get(path_source, '')}", "PROMPT": f"(roundtrip$$P) {prompt}", } assert [json.loads(line) for line in result.stdout.splitlines()] == [ active, active, - {"VIRTUAL_ENV": None, "PATH": path, "PROMPT": prompt}, + {"VIRTUAL_ENV": None, "PATH": os.environ.get(path_source), "PROMPT": prompt}, {**active, "PATH": f"{creator.bin_dir};{os.environ['SYSTEMROOT']}"}, ] assert not result.stderr