From 6dcadeedee5402339441acfc0c3a7baf1d26ce6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emre=20=C5=9Eafak?= <3928300+esafak@users.noreply.github.com> Date: Thu, 14 Aug 2025 04:05:14 +0000 Subject: [PATCH 01/17] feat: added support for `pkg-config` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To do this, I modified the activation scripts for all supported shells. Now, when you activate a virtual environment, the `lib/pkgconfig` directory will be prepended to the `PKG_CONFIG_PATH` environment variable. The original value is restored upon deactivation. I also added unit tests for each shell to verify that the activation scripts are generated correctly and that `PKG_CONFIG_PATH` is set and unset as expected. Signed-off-by: Emre Şafak <3928300+esafak@users.noreply.github.com> --- docs/changelog/2637.feature.rst | 1 + src/virtualenv/activation/bash/activate.sh | 16 +++++ src/virtualenv/activation/batch/activate.bat | 3 + .../activation/batch/deactivate.bat | 4 ++ src/virtualenv/activation/cshell/activate.csh | 9 ++- src/virtualenv/activation/fish/activate.fish | 14 ++++ src/virtualenv/activation/nushell/activate.nu | 6 ++ .../activation/powershell/activate.ps1 | 18 +++++ .../activation/python/activate_this.py | 8 +++ src/virtualenv/activation/via_template.py | 1 + tests/unit/activation/test_bash.py | 45 +++++++++++++ tests/unit/activation/test_batch.py | 42 ++++++++++++ tests/unit/activation/test_csh.py | 41 ++++++++++++ tests/unit/activation/test_fish.py | 41 ++++++++++++ tests/unit/activation/test_nushell.py | 34 ++++++++++ tests/unit/activation/test_powershell.py | 43 ++++++++++++ .../unit/activation/test_python_activator.py | 66 ++++++++++++++++++- 17 files changed, 390 insertions(+), 2 deletions(-) create mode 100644 docs/changelog/2637.feature.rst diff --git a/docs/changelog/2637.feature.rst b/docs/changelog/2637.feature.rst new file mode 100644 index 000000000..d420a7e9d --- /dev/null +++ b/docs/changelog/2637.feature.rst @@ -0,0 +1 @@ +Added support for `pkg-config` - by :user:`esafak`. diff --git a/src/virtualenv/activation/bash/activate.sh b/src/virtualenv/activation/bash/activate.sh index b54358e9e..c0b7c701c 100644 --- a/src/virtualenv/activation/bash/activate.sh +++ b/src/virtualenv/activation/bash/activate.sh @@ -34,6 +34,14 @@ deactivate () { unset _OLD_VIRTUAL_TK_LIBRARY fi + if ! [ -z "${_OLD_VIRTUAL_PKG_CONFIG_PATH+_}" ]; then + PKG_CONFIG_PATH="$_OLD_VIRTUAL_PKG_CONFIG_PATH" + export PKG_CONFIG_PATH + unset _OLD_VIRTUAL_PKG_CONFIG_PATH + else + unset PKG_CONFIG_PATH + fi + # The hash command must be called to get it to forget past # commands. Without forgetting past commands the $PATH changes # we made may not be respected @@ -66,6 +74,14 @@ _OLD_VIRTUAL_PATH="$PATH" PATH="$VIRTUAL_ENV/"__BIN_NAME__":$PATH" export PATH +if ! [ -z "${PKG_CONFIG_PATH+_}" ]; then + _OLD_VIRTUAL_PKG_CONFIG_PATH="$PKG_CONFIG_PATH" + PKG_CONFIG_PATH=__PKG_CONFIG_PATH__${__PATH_SEP__}$PKG_CONFIG_PATH +else + PKG_CONFIG_PATH=__PKG_CONFIG_PATH__ +fi +export PKG_CONFIG_PATH + if [ "x"__VIRTUAL_PROMPT__ != x ] ; then VIRTUAL_ENV_PROMPT=__VIRTUAL_PROMPT__ else diff --git a/src/virtualenv/activation/batch/activate.bat b/src/virtualenv/activation/batch/activate.bat index 62f393c80..8997a192e 100644 --- a/src/virtualenv/activation/batch/activate.bat +++ b/src/virtualenv/activation/batch/activate.bat @@ -39,6 +39,9 @@ @if defined TK_LIBRARY @set "_OLD_VIRTUAL_TK_LIBRARY=%TK_LIBRARY%" @if NOT "__TK_LIBRARY__"=="" @set "TK_LIBRARY=__TK_LIBRARY__" +@if defined PKG_CONFIG_PATH @set "_OLD_VIRTUAL_PKG_CONFIG_PATH=%PKG_CONFIG_PATH%" +@if defined _OLD_VIRTUAL_PKG_CONFIG_PATH (@set PKG_CONFIG_PATH=__PKG_CONFIG_PATH__;%_OLD_VIRTUAL_PKG_CONFIG_PATH%) else (@set PKG_CONFIG_PATH=__PKG_CONFIG_PATH__) + @REM if defined _OLD_VIRTUAL_PATH ( @if not defined _OLD_VIRTUAL_PATH @goto ENDIFVPATH1 @set "PATH=%_OLD_VIRTUAL_PATH%" diff --git a/src/virtualenv/activation/batch/deactivate.bat b/src/virtualenv/activation/batch/deactivate.bat index 7a12d47ed..89d4c29e5 100644 --- a/src/virtualenv/activation/batch/deactivate.bat +++ b/src/virtualenv/activation/batch/deactivate.bat @@ -20,6 +20,10 @@ @if not defined _OLD_VIRTUAL_TK_LIBRARY @set TK_LIBRARY= @set _OLD_VIRTUAL_TK_LIBRARY= +@if defined _OLD_VIRTUAL_PKG_CONFIG_PATH @set "PKG_CONFIG_PATH=%_OLD_VIRTUAL_PKG_CONFIG_PATH%" +@if not defined _OLD_VIRTUAL_PKG_CONFIG_PATH @set PKG_CONFIG_PATH= +@set _OLD_VIRTUAL_PKG_CONFIG_PATH= + @if not defined _OLD_VIRTUAL_PATH @goto ENDIFVPATH @set "PATH=%_OLD_VIRTUAL_PATH%" @set _OLD_VIRTUAL_PATH= diff --git a/src/virtualenv/activation/cshell/activate.csh b/src/virtualenv/activation/cshell/activate.csh index 5c02616d7..479c42e6f 100644 --- a/src/virtualenv/activation/cshell/activate.csh +++ b/src/virtualenv/activation/cshell/activate.csh @@ -5,7 +5,7 @@ set newline='\ ' -alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH:q" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_TCL_LIBRARY != 0 && setenv TCL_LIBRARY "$_OLD_VIRTUAL_TCL_LIBRARY:q" && unset _OLD_VIRTUAL_TCL_LIBRARY || unsetenv TCL_LIBRARY; test $?_OLD_VIRTUAL_TK_LIBRARY != 0 && setenv TK_LIBRARY "$_OLD_VIRTUAL_TK_LIBRARY:q" && unset _OLD_VIRTUAL_TK_LIBRARY || unsetenv TK_LIBRARY; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT:q" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate && unalias pydoc' +alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH:q" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_TCL_LIBRARY != 0 && setenv TCL_LIBRARY "$_OLD_VIRTUAL_TCL_LIBRARY:q" && unset _OLD_VIRTUAL_TCL_LIBRARY || unsetenv TCL_LIBRARY; test $?_OLD_VIRTUAL_TK_LIBRARY != 0 && setenv TK_LIBRARY "$_OLD_VIRTUAL_TK_LIBRARY:q" && unset _OLD_VIRTUAL_TK_LIBRARY || unsetenv TK_LIBRARY; test $?_OLD_VIRTUAL_PKG_CONFIG_PATH != 0 && setenv PKG_CONFIG_PATH "$_OLD_VIRTUAL_PKG_CONFIG_PATH:q" && unset _OLD_VIRTUAL_PKG_CONFIG_PATH || unsetenv PKG_CONFIG_PATH; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT:q" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate && unalias pydoc' # Unset irrelevant variables. deactivate nondestructive @@ -29,6 +29,13 @@ if (__TK_LIBRARY__ != "") then setenv TK_LIBRARY __TK_LIBRARY__ endif +if ($?PKG_CONFIG_PATH) then + set _OLD_VIRTUAL_PKG_CONFIG_PATH="$PKG_CONFIG_PATH:q" + setenv PKG_CONFIG_PATH __PKG_CONFIG_PATH__:$PKG_CONFIG_PATH:q +else + setenv PKG_CONFIG_PATH __PKG_CONFIG_PATH__ +endif + if (__VIRTUAL_PROMPT__ != "") then setenv VIRTUAL_ENV_PROMPT __VIRTUAL_PROMPT__ else diff --git a/src/virtualenv/activation/fish/activate.fish b/src/virtualenv/activation/fish/activate.fish index c9d174997..b6bb2bdec 100644 --- a/src/virtualenv/activation/fish/activate.fish +++ b/src/virtualenv/activation/fish/activate.fish @@ -43,6 +43,13 @@ function deactivate -d 'Exit virtualenv mode and return to the normal environmen end end + if test -n "$_OLD_VIRTUAL_PKG_CONFIG_PATH" + set -gx PKG_CONFIG_PATH "$_OLD_VIRTUAL_PKG_CONFIG_PATH" + set -e _OLD_VIRTUAL_PKG_CONFIG_PATH + else + set -e PKG_CONFIG_PATH + end + if test -n "$_OLD_VIRTUAL_PYTHONHOME" set -gx PYTHONHOME "$_OLD_VIRTUAL_PYTHONHOME" set -e _OLD_VIRTUAL_PYTHONHOME @@ -98,6 +105,13 @@ if test -n __TK_LIBRARY__ set -gx TK_LIBRARY '__TK_LIBRARY__' end +if set -q PKG_CONFIG_PATH + set -gx _OLD_VIRTUAL_PKG_CONFIG_PATH $PKG_CONFIG_PATH + set -gx PKG_CONFIG_PATH __PKG_CONFIG_PATH__ $PKG_CONFIG_PATH +else + set -gx PKG_CONFIG_PATH __PKG_CONFIG_PATH__ +end + # Prompt override provided? # If not, just use the environment name. if test -n __VIRTUAL_PROMPT__ diff --git a/src/virtualenv/activation/nushell/activate.nu b/src/virtualenv/activation/nushell/activate.nu index b48fdd03f..6cd93b07d 100644 --- a/src/virtualenv/activation/nushell/activate.nu +++ b/src/virtualenv/activation/nushell/activate.nu @@ -64,6 +64,12 @@ export-env { if (has-env 'TK_LIBRARY') { let $new_env = $new_env | insert TK_LIBRARY __TK_LIBRARY__ } + let $new_env = if (has-env 'PKG_CONFIG_PATH') { + let pkg_config_path = ($env.PKG_CONFIG_PATH | prepend __PKG_CONFIG_PATH__) + $new_env | insert PKG_CONFIG_PATH $pkg_config_path + } else { + $new_env | insert PKG_CONFIG_PATH [__PKG_CONFIG_PATH__] + } let old_prompt_command = if (has-env 'PROMPT_COMMAND') { $env.PROMPT_COMMAND } else { '' } let new_env = if (is-env-true 'VIRTUAL_ENV_DISABLE_PROMPT') { $new_env diff --git a/src/virtualenv/activation/powershell/activate.ps1 b/src/virtualenv/activation/powershell/activate.ps1 index 9f95e4370..cc7c509ae 100644 --- a/src/virtualenv/activation/powershell/activate.ps1 +++ b/src/virtualenv/activation/powershell/activate.ps1 @@ -25,6 +25,16 @@ function global:deactivate([switch] $NonDestructive) { } } + if (Test-Path variable:_OLD_VIRTUAL_PKG_CONFIG_PATH) { + $env:PKG_CONFIG_PATH = $variable:_OLD_VIRTUAL_PKG_CONFIG_PATH + Remove-Variable "_OLD_VIRTUAL_PKG_CONFIG_PATH" -Scope global + } + else { + if (Test-Path env:PKG_CONFIG_PATH) { + Remove-Item env:PKG_CONFIG_PATH -ErrorAction SilentlyContinue + } + } + if (Test-Path function:_old_virtual_prompt) { $function:prompt = $function:_old_virtual_prompt Remove-Item function:\_old_virtual_prompt @@ -76,6 +86,14 @@ if (__TK_LIBRARY__ -ne "") { $env:TK_LIBRARY = __TK_LIBRARY__ } +if (Test-Path env:PKG_CONFIG_PATH) { + New-Variable -Scope global -Name _OLD_VIRTUAL_PKG_CONFIG_PATH -Value $env:PKG_CONFIG_PATH + $env:PKG_CONFIG_PATH = __PKG_CONFIG_PATH__ + __PATH_SEP__ + $env:PKG_CONFIG_PATH +} +else { + $env:PKG_CONFIG_PATH = __PKG_CONFIG_PATH__ +} + New-Variable -Scope global -Name _OLD_VIRTUAL_PATH -Value $env:PATH $env:PATH = "$env:VIRTUAL_ENV/" + __BIN_NAME__ + __PATH_SEP__ + $env:PATH diff --git a/src/virtualenv/activation/python/activate_this.py b/src/virtualenv/activation/python/activate_this.py index 9cc816fab..6b55d3683 100644 --- a/src/virtualenv/activation/python/activate_this.py +++ b/src/virtualenv/activation/python/activate_this.py @@ -24,6 +24,14 @@ # prepend bin to PATH (this file is inside the bin directory) os.environ["PATH"] = os.pathsep.join([bin_dir, *os.environ.get("PATH", "").split(os.pathsep)]) + +# set PKG_CONFIG_PATH +pkg_config_path = os.path.join(base, "lib", "pkgconfig") +if "PKG_CONFIG_PATH" in os.environ: + os.environ["PKG_CONFIG_PATH"] = os.pathsep.join([pkg_config_path, os.environ["PKG_CONFIG_PATH"]]) +else: + os.environ["PKG_CONFIG_PATH"] = pkg_config_path + os.environ["VIRTUAL_ENV"] = base # virtual env is right above bin directory os.environ["VIRTUAL_ENV_PROMPT"] = __VIRTUAL_PROMPT__ or os.path.basename(base) diff --git a/src/virtualenv/activation/via_template.py b/src/virtualenv/activation/via_template.py index 83229441a..f3cdc8adf 100644 --- a/src/virtualenv/activation/via_template.py +++ b/src/virtualenv/activation/via_template.py @@ -49,6 +49,7 @@ def replacements(self, creator, dest_folder): # noqa: ARG002 "__PATH_SEP__": os.pathsep, "__TCL_LIBRARY__": creator.interpreter.tcl_lib or "", "__TK_LIBRARY__": creator.interpreter.tk_lib or "", + "__PKG_CONFIG_PATH__": str(creator.dest / "lib" / "pkgconfig"), } def _generate(self, replacements, templates, to_folder, creator): diff --git a/tests/unit/activation/test_bash.py b/tests/unit/activation/test_bash.py index 0a9c588cf..1bf55608f 100644 --- a/tests/unit/activation/test_bash.py +++ b/tests/unit/activation/test_bash.py @@ -1,5 +1,7 @@ from __future__ import annotations +import os +import shlex from argparse import Namespace import pytest @@ -63,6 +65,49 @@ def __init__(self, dest): assert "export TCL_LIBRARY" in content +@pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash") +def test_bash_pkg_config_generation(tmp_path): + # GIVEN + class MockCreator: + def __init__(self, dest): + self.dest = dest + self.bin_dir = dest / "bin" + self.bin_dir.mkdir() + self.pyenv_cfg = {} + self.env_name = "my-env" + + class MockInterpreter: + pass + + self.interpreter = MockInterpreter() + self.interpreter.tcl_lib = None + self.interpreter.tk_lib = None + + creator = MockCreator(tmp_path) + options = Namespace(prompt=None) + activator = BashActivator(options) + + # WHEN + activator.generate(creator) + content = (creator.bin_dir / "activate").read_text(encoding="utf-8") + + # THEN + pkg_config_path = str(tmp_path / "lib" / "pkgconfig") + content = content.replace(shlex.quote(pkg_config_path), "__PKG_CONFIG_PATH__") + content = content.replace(shlex.quote(os.pathsep), "__PATH_SEP__") + + assert 'if ! [ -z "${PKG_CONFIG_PATH+_}" ]; then' in content + assert '_OLD_VIRTUAL_PKG_CONFIG_PATH="$PKG_CONFIG_PATH"' in content + assert "PKG_CONFIG_PATH=__PKG_CONFIG_PATH__${__PATH_SEP__}$PKG_CONFIG_PATH" in content + assert "else" in content + assert "PKG_CONFIG_PATH=__PKG_CONFIG_PATH__" in content + assert "export PKG_CONFIG_PATH" in content + assert 'if ! [ -z "${_OLD_VIRTUAL_PKG_CONFIG_PATH+_}" ]; then' in content + assert 'PKG_CONFIG_PATH="$_OLD_VIRTUAL_PKG_CONFIG_PATH"' in content + assert "unset _OLD_VIRTUAL_PKG_CONFIG_PATH" in content + assert "unset PKG_CONFIG_PATH" in content + + @pytest.mark.skipif(IS_WIN, reason="Github Actions ships with WSL bash") @pytest.mark.parametrize("hashing_enabled", [True, False]) def test_bash(raise_on_non_source_class, hashing_enabled, activation_tester): diff --git a/tests/unit/activation/test_batch.py b/tests/unit/activation/test_batch.py index db2595860..617865e63 100644 --- a/tests/unit/activation/test_batch.py +++ b/tests/unit/activation/test_batch.py @@ -52,6 +52,48 @@ def __init__(self, dest): assert '@if NOT ""=="" @set "TK_LIBRARY="' in activate_content +def test_batch_pkg_config_generation(tmp_path): + # GIVEN + class MockInterpreter: + os = "nt" + + interpreter = MockInterpreter() + interpreter.tcl_lib = None + interpreter.tk_lib = None + + class MockCreator: + def __init__(self, dest): + self.dest = dest + self.bin_dir = dest / "bin" + self.bin_dir.mkdir() + self.interpreter = interpreter + self.pyenv_cfg = {} + self.env_name = "my-env" + + creator = MockCreator(tmp_path) + options = Namespace(prompt=None) + activator = BatchActivator(options) + + # WHEN + activator.generate(creator) + activate_content = (creator.bin_dir / "activate.bat").read_text(encoding="utf-8") + deactivate_content = (creator.bin_dir / "deactivate.bat").read_text(encoding="utf-8") + + # THEN + pkg_config_path = str(tmp_path / "lib" / "pkgconfig") + activate_content = activate_content.replace(BatchActivator.quote(pkg_config_path), "__PKG_CONFIG_PATH__") + + assert '@if defined PKG_CONFIG_PATH @set "_OLD_VIRTUAL_PKG_CONFIG_PATH=%PKG_CONFIG_PATH%"' in activate_content + assert "(@set PKG_CONFIG_PATH=__PKG_CONFIG_PATH__;%_OLD_VIRTUAL_PKG_CONFIG_PATH%)" in activate_content + assert "else (@set PKG_CONFIG_PATH=__PKG_CONFIG_PATH__)" in activate_content + assert ( + '@if defined _OLD_VIRTUAL_PKG_CONFIG_PATH @set "PKG_CONFIG_PATH=%_OLD_VIRTUAL_PKG_CONFIG_PATH%"' + in deactivate_content + ) + assert "@if not defined _OLD_VIRTUAL_PKG_CONFIG_PATH @set PKG_CONFIG_PATH=" in deactivate_content + assert "@set _OLD_VIRTUAL_PKG_CONFIG_PATH=" in deactivate_content + + @pytest.mark.usefixtures("activation_python") def test_batch(activation_tester_class, activation_tester, tmp_path): version_script = tmp_path / "version.bat" diff --git a/tests/unit/activation/test_csh.py b/tests/unit/activation/test_csh.py index 5cea684ec..af43b4857 100644 --- a/tests/unit/activation/test_csh.py +++ b/tests/unit/activation/test_csh.py @@ -53,6 +53,47 @@ def __init__(self, dest): assert "setenv TCL_LIBRARY ''" in content +def test_cshell_pkg_config_generation(tmp_path): + # GIVEN + class MockInterpreter: + pass + + interpreter = MockInterpreter() + interpreter.tcl_lib = None + interpreter.tk_lib = None + + class MockCreator: + def __init__(self, dest): + self.dest = dest + self.bin_dir = dest / "bin" + self.bin_dir.mkdir() + self.interpreter = interpreter + self.pyenv_cfg = {} + self.env_name = "my-env" + + creator = MockCreator(tmp_path) + options = Namespace(prompt=None) + activator = CShellActivator(options) + + # WHEN + activator.generate(creator) + content = (creator.bin_dir / "activate.csh").read_text(encoding="utf-8") + + # THEN + pkg_config_path = str(tmp_path / "lib" / "pkgconfig") + content = content.replace(CShellActivator.quote(pkg_config_path), "__PKG_CONFIG_PATH__") + + assert "if ($?PKG_CONFIG_PATH) then" in content + assert 'set _OLD_VIRTUAL_PKG_CONFIG_PATH="$PKG_CONFIG_PATH:q"' in content + assert "setenv PKG_CONFIG_PATH __PKG_CONFIG_PATH__:$PKG_CONFIG_PATH:q" in content + assert "else" in content + assert "setenv PKG_CONFIG_PATH __PKG_CONFIG_PATH__" in content + assert "test $?_OLD_VIRTUAL_PKG_CONFIG_PATH != 0" in content + assert 'setenv PKG_CONFIG_PATH "$_OLD_VIRTUAL_PKG_CONFIG_PATH:q"' in content + assert "unset _OLD_VIRTUAL_PKG_CONFIG_PATH" in content + assert "unsetenv PKG_CONFIG_PATH" in content + + def test_csh(activation_tester_class, activation_tester): exe = f"tcsh{'.exe' if sys.platform == 'win32' else ''}" if which(exe): diff --git a/tests/unit/activation/test_fish.py b/tests/unit/activation/test_fish.py index c15a8f513..c1010d2b4 100644 --- a/tests/unit/activation/test_fish.py +++ b/tests/unit/activation/test_fish.py @@ -52,6 +52,47 @@ def __init__(self, dest): assert "if test -n ''\n if set -q TK_LIBRARY;" in content +def test_fish_pkg_config_generation(tmp_path): + # GIVEN + class MockInterpreter: + pass + + interpreter = MockInterpreter() + interpreter.tcl_lib = None + interpreter.tk_lib = None + + class MockCreator: + def __init__(self, dest): + self.dest = dest + self.bin_dir = dest / "bin" + self.bin_dir.mkdir() + self.interpreter = interpreter + self.pyenv_cfg = {} + self.env_name = "my-env" + + creator = MockCreator(tmp_path) + options = Namespace(prompt=None) + activator = FishActivator(options) + + # WHEN + activator.generate(creator) + content = (creator.bin_dir / "activate.fish").read_text(encoding="utf-8") + + # THEN + pkg_config_path = str(tmp_path / "lib" / "pkgconfig") + content = content.replace(FishActivator.quote(pkg_config_path), "__PKG_CONFIG_PATH__") + + assert "if set -q PKG_CONFIG_PATH" in content + assert "set -gx _OLD_VIRTUAL_PKG_CONFIG_PATH $PKG_CONFIG_PATH" in content + assert "set -gx PKG_CONFIG_PATH __PKG_CONFIG_PATH__ $PKG_CONFIG_PATH" in content + assert "else" in content + assert "set -gx PKG_CONFIG_PATH __PKG_CONFIG_PATH__" in content + assert 'if test -n "$_OLD_VIRTUAL_PKG_CONFIG_PATH"' in content + assert 'set -gx PKG_CONFIG_PATH "$_OLD_VIRTUAL_PKG_CONFIG_PATH"' in content + assert "set -e _OLD_VIRTUAL_PKG_CONFIG_PATH" in content + assert "set -e PKG_CONFIG_PATH" in content + + @pytest.mark.skipif(IS_WIN, reason="we have not setup fish in CI yet") def test_fish(activation_tester_class, activation_tester, monkeypatch, tmp_path): monkeypatch.setenv("HOME", str(tmp_path)) diff --git a/tests/unit/activation/test_nushell.py b/tests/unit/activation/test_nushell.py index 08c5cb1a1..21dd16cc6 100644 --- a/tests/unit/activation/test_nushell.py +++ b/tests/unit/activation/test_nushell.py @@ -43,6 +43,40 @@ def __init__(self, dest): assert expected_tk in content +def test_nushell_pkg_config_generation(tmp_path): + # GIVEN + class MockInterpreter: + pass + + interpreter = MockInterpreter() + interpreter.tcl_lib = None + interpreter.tk_lib = None + + class MockCreator: + def __init__(self, dest): + self.dest = dest + self.bin_dir = dest / "bin" + self.bin_dir.mkdir() + self.interpreter = interpreter + self.pyenv_cfg = {} + self.env_name = "my-env" + + creator = MockCreator(tmp_path) + options = Namespace(prompt=None) + activator = NushellActivator(options) + + # WHEN + activator.generate(creator) + content = (creator.bin_dir / "activate.nu").read_text(encoding="utf-8") + + # THEN + assert "let $new_env = if (has-env 'PKG_CONFIG_PATH') {" in content + assert "let pkg_config_path = ($env.PKG_CONFIG_PATH | prepend __PKG_CONFIG_PATH__)" in content + assert "$new_env | insert PKG_CONFIG_PATH $pkg_config_path" in content + assert "else {" in content + assert "$new_env | insert PKG_CONFIG_PATH [__PKG_CONFIG_PATH__]" in content + + def test_nushell(activation_tester_class, activation_tester): class Nushell(activation_tester_class): def __init__(self, session) -> None: diff --git a/tests/unit/activation/test_powershell.py b/tests/unit/activation/test_powershell.py index 2a48956cf..995834494 100644 --- a/tests/unit/activation/test_powershell.py +++ b/tests/unit/activation/test_powershell.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import sys from argparse import Namespace @@ -54,6 +55,48 @@ def __init__(self, dest): assert "$env:TCL_LIBRARY = ''" in content +def test_powershell_pkg_config_generation(tmp_path): + # GIVEN + class MockInterpreter: + os = "nt" + + interpreter = MockInterpreter() + interpreter.tcl_lib = None + interpreter.tk_lib = None + + class MockCreator: + def __init__(self, dest): + self.dest = dest + self.bin_dir = dest / "bin" + self.bin_dir.mkdir() + self.interpreter = interpreter + self.pyenv_cfg = {} + self.env_name = "my-env" + + creator = MockCreator(tmp_path) + options = Namespace(prompt=None) + activator = PowerShellActivator(options) + + # WHEN + activator.generate(creator) + content = (creator.bin_dir / "activate.ps1").read_text(encoding="utf-8-sig") + + # THEN + pkg_config_path = str(tmp_path / "lib" / "pkgconfig") + content = content.replace(PowerShellActivator.quote(pkg_config_path), "__PKG_CONFIG_PATH__") + content = content.replace(PowerShellActivator.quote(os.pathsep), "__PATH_SEP__") + + assert "if (Test-Path env:PKG_CONFIG_PATH)" in content + assert "New-Variable -Scope global -Name _OLD_VIRTUAL_PKG_CONFIG_PATH -Value $env:PKG_CONFIG_PATH" in content + assert "$env:PKG_CONFIG_PATH = __PKG_CONFIG_PATH__ + __PATH_SEP__ + $env:PKG_CONFIG_PATH" in content + assert "else" in content + assert "$env:PKG_CONFIG_PATH = __PKG_CONFIG_PATH__" in content + assert "if (Test-Path variable:_OLD_VIRTUAL_PKG_CONFIG_PATH)" in content + assert "$env:PKG_CONFIG_PATH = $variable:_OLD_VIRTUAL_PKG_CONFIG_PATH" in content + assert 'Remove-Variable "_OLD_VIRTUAL_PKG_CONFIG_PATH" -Scope global' in content + assert "Remove-Item env:PKG_CONFIG_PATH -ErrorAction SilentlyContinue" in content + + @pytest.mark.slow def test_powershell(activation_tester_class, activation_tester, monkeypatch): monkeypatch.setenv("TERM", "xterm") diff --git a/tests/unit/activation/test_python_activator.py b/tests/unit/activation/test_python_activator.py index 24a3561c5..13fb3f545 100644 --- a/tests/unit/activation/test_python_activator.py +++ b/tests/unit/activation/test_python_activator.py @@ -5,6 +5,8 @@ from ast import literal_eval from textwrap import dedent +import pytest + from virtualenv.activation import PythonActivator from virtualenv.info import IS_WIN @@ -59,7 +61,7 @@ def print_r(value): """ return dedent(raw).splitlines() - def assert_output(self, out, raw, tmp_path): # noqa: ARG002 + def assert_output(self, out, _raw, _tmp_path): out = [literal_eval(i) for i in out] assert out[0] is None # start with VIRTUAL_ENV None assert out[1] is None # likewise for VIRTUAL_ENV_PROMPT @@ -90,3 +92,65 @@ def non_source_activate(self, activate_script): return [*self._invoke_script, "-c", f"exec(open({act!r}).read())"] activation_tester(Python) + + +@pytest.mark.parametrize("pkg_config_path", [None, "/data/foo"]) +def test_python_pkg_config(raise_on_non_source_class, activation_tester, pkg_config_path, monkeypatch): + if pkg_config_path: + monkeypatch.setenv("PKG_CONFIG_PATH", pkg_config_path) + + class Python(raise_on_non_source_class): + def __init__(self, session) -> None: + super().__init__( + PythonActivator, + session, + sys.executable, + activate_script="activate_this.py", + extension="py", + non_source_fail_message="You must use import runpy; runpy.run_path(this_file)", + ) + self.unix_line_ending = not IS_WIN + + def env(self, tmp_path): + env = super().env(tmp_path) + if pkg_config_path is None and "PKG_CONFIG_PATH" in env: + del env["PKG_CONFIG_PATH"] + return env + + @staticmethod + def _get_test_lines(activate_script): + raw = f""" + import os + import sys + import runpy + + def print_r(value): + print(repr(value)) + + print_r(os.environ.get("PKG_CONFIG_PATH")) + file_at = {str(activate_script)!r} + runpy.run_path(file_at) + print_r(os.environ.get("PKG_CONFIG_PATH")) + """ + return dedent(raw).splitlines() + + def assert_output(self, out, _raw, _tmp_path): + out = [literal_eval(i) for i in out] + + # Before activation + assert out[0] == pkg_config_path + + # After activation + venv_pkg_config = str(self._creator.dest / "lib" / "pkgconfig") + if pkg_config_path is None: + expected = venv_pkg_config + else: + expected = os.pathsep.join([venv_pkg_config, pkg_config_path]) + + assert out[1] == expected + + def non_source_activate(self, activate_script): + act = str(activate_script) + return [*self._invoke_script, "-c", f"exec(open({act!r}).read())"] + + activation_tester(Python) From 5a05e6b2075c46025cd1803445d5e092b4d5bc22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emre=20=C5=9Eafak?= <3928300+esafak@users.noreply.github.com> Date: Thu, 14 Aug 2025 01:09:32 -0400 Subject: [PATCH 02/17] Harden BatchActivator.quote() for special_char_name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Emre Şafak <3928300+esafak@users.noreply.github.com> --- src/virtualenv/activation/batch/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/virtualenv/activation/batch/__init__.py b/src/virtualenv/activation/batch/__init__.py index 3d74ba835..5efb022b4 100644 --- a/src/virtualenv/activation/batch/__init__.py +++ b/src/virtualenv/activation/batch/__init__.py @@ -17,7 +17,7 @@ def templates(self): @staticmethod def quote(string): - return string + return string.replace("&", "^&") def instantiate_template(self, replacements, template, creator): # ensure the text has all newlines as \r\n - required by batch From 86c5d264934cbb38a2a3302d2fb484daf12e0309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emre=20=C5=9Eafak?= <3928300+esafak@users.noreply.github.com> Date: Thu, 14 Aug 2025 09:57:25 -0400 Subject: [PATCH 03/17] Harden Batch.quote() for special_char_name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Emre Şafak <3928300+esafak@users.noreply.github.com> --- src/virtualenv/activation/batch/__init__.py | 2 +- tests/unit/activation/test_batch.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/virtualenv/activation/batch/__init__.py b/src/virtualenv/activation/batch/__init__.py index 5efb022b4..3d74ba835 100644 --- a/src/virtualenv/activation/batch/__init__.py +++ b/src/virtualenv/activation/batch/__init__.py @@ -17,7 +17,7 @@ def templates(self): @staticmethod def quote(string): - return string.replace("&", "^&") + return string def instantiate_template(self, replacements, template, creator): # ensure the text has all newlines as \r\n - required by batch diff --git a/tests/unit/activation/test_batch.py b/tests/unit/activation/test_batch.py index 617865e63..bdbe5c9e4 100644 --- a/tests/unit/activation/test_batch.py +++ b/tests/unit/activation/test_batch.py @@ -114,7 +114,7 @@ def _get_test_lines(self, activate_script): def quote(self, s): if '"' in s or " " in s: - text = s.replace('"', r"\"") + text = s.replace('"', r"\"").replace("&", "^&") return f'"{text}"' return s From b773b9bac3bd5c9174bfa0f0362522bf7fe2d41d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emre=20=C5=9Eafak?= <3928300+esafak@users.noreply.github.com> Date: Thu, 14 Aug 2025 11:31:31 -0400 Subject: [PATCH 04/17] Properly escape '&' in batch activator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Escapes the '&' character within the VIRTUAL_ENV_PROMPT variable in the batch activator. This prevents issues with batch scripts interpreting '&' as a command separator. * Removes an unnecessary '&' escaping from the test helper function `quote`, as the main logic now handles this correctly. Signed-off-by: Emre Şafak <3928300+esafak@users.noreply.github.com> --- src/virtualenv/activation/batch/__init__.py | 8 ++++++++ tests/unit/activation/test_batch.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/virtualenv/activation/batch/__init__.py b/src/virtualenv/activation/batch/__init__.py index 3d74ba835..dea239994 100644 --- a/src/virtualenv/activation/batch/__init__.py +++ b/src/virtualenv/activation/batch/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import re from virtualenv.activation.via_template import ViaTemplateActivator @@ -22,6 +23,13 @@ def quote(string): def instantiate_template(self, replacements, template, creator): # ensure the text has all newlines as \r\n - required by batch base = super().instantiate_template(replacements, template, creator) + # escape & in VIRTUAL_ENV_PROMPT + base = re.sub( + r'(@set "VIRTUAL_ENV_PROMPT=)(.*?)(")', + lambda m: m.group(1) + m.group(2).replace("&", "^&") + m.group(3), + base, + flags=re.MULTILINE, + ) return base.replace(os.linesep, "\n").replace("\n", os.linesep) diff --git a/tests/unit/activation/test_batch.py b/tests/unit/activation/test_batch.py index bdbe5c9e4..617865e63 100644 --- a/tests/unit/activation/test_batch.py +++ b/tests/unit/activation/test_batch.py @@ -114,7 +114,7 @@ def _get_test_lines(self, activate_script): def quote(self, s): if '"' in s or " " in s: - text = s.replace('"', r"\"").replace("&", "^&") + text = s.replace('"', r"\"") return f'"{text}"' return s From 6a32685700cad3581cf11311e69de8814f8822e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emre=20=C5=9Eafak?= <3928300+esafak@users.noreply.github.com> Date: Thu, 14 Aug 2025 11:59:56 -0400 Subject: [PATCH 05/17] try fixing it in the batch file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Emre Şafak <3928300+esafak@users.noreply.github.com> --- src/virtualenv/activation/batch/__init__.py | 7 ------- src/virtualenv/activation/batch/activate.bat | 1 + 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/virtualenv/activation/batch/__init__.py b/src/virtualenv/activation/batch/__init__.py index dea239994..a444f80bb 100644 --- a/src/virtualenv/activation/batch/__init__.py +++ b/src/virtualenv/activation/batch/__init__.py @@ -23,13 +23,6 @@ def quote(string): def instantiate_template(self, replacements, template, creator): # ensure the text has all newlines as \r\n - required by batch base = super().instantiate_template(replacements, template, creator) - # escape & in VIRTUAL_ENV_PROMPT - base = re.sub( - r'(@set "VIRTUAL_ENV_PROMPT=)(.*?)(")', - lambda m: m.group(1) + m.group(2).replace("&", "^&") + m.group(3), - base, - flags=re.MULTILINE, - ) return base.replace(os.linesep, "\n").replace("\n", os.linesep) diff --git a/src/virtualenv/activation/batch/activate.bat b/src/virtualenv/activation/batch/activate.bat index 8997a192e..0bb599cf7 100644 --- a/src/virtualenv/activation/batch/activate.bat +++ b/src/virtualenv/activation/batch/activate.bat @@ -10,6 +10,7 @@ @set "VIRTUAL_ENV_PROMPT=__VIRTUAL_PROMPT__" @if NOT DEFINED VIRTUAL_ENV_PROMPT ( @for %%d in ("%VIRTUAL_ENV%") do @set "VIRTUAL_ENV_PROMPT=%%~nxd" + @set "VIRTUAL_ENV_PROMPT=%VIRTUAL_ENV_PROMPT:&=^&%" ) @if defined _OLD_VIRTUAL_PROMPT ( From 36101691b4bc2ee0aae28a4abf180a06ec83cdb8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 15 Aug 2025 00:59:22 +0000 Subject: [PATCH 06/17] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/virtualenv/activation/batch/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/virtualenv/activation/batch/__init__.py b/src/virtualenv/activation/batch/__init__.py index a444f80bb..3d74ba835 100644 --- a/src/virtualenv/activation/batch/__init__.py +++ b/src/virtualenv/activation/batch/__init__.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -import re from virtualenv.activation.via_template import ViaTemplateActivator From 9d8150f94607568f8e32a96f7b506d692f963398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emre=20=C5=9Eafak?= <3928300+esafak@users.noreply.github.com> Date: Thu, 14 Aug 2025 21:16:38 -0400 Subject: [PATCH 07/17] move the set command outside the if block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Emre Şafak <3928300+esafak@users.noreply.github.com> --- src/virtualenv/activation/batch/activate.bat | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/virtualenv/activation/batch/activate.bat b/src/virtualenv/activation/batch/activate.bat index 0bb599cf7..85f6aff0f 100644 --- a/src/virtualenv/activation/batch/activate.bat +++ b/src/virtualenv/activation/batch/activate.bat @@ -10,8 +10,8 @@ @set "VIRTUAL_ENV_PROMPT=__VIRTUAL_PROMPT__" @if NOT DEFINED VIRTUAL_ENV_PROMPT ( @for %%d in ("%VIRTUAL_ENV%") do @set "VIRTUAL_ENV_PROMPT=%%~nxd" - @set "VIRTUAL_ENV_PROMPT=%VIRTUAL_ENV_PROMPT:&=^&%" ) +@set "VIRTUAL_ENV_PROMPT=%VIRTUAL_ENV_PROMPT:&=^&%" @if defined _OLD_VIRTUAL_PROMPT ( @set "PROMPT=%_OLD_VIRTUAL_PROMPT%" From 4eae484d5bbd366ab0338075d6ccef8162a4f1ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emre=20=C5=9Eafak?= <3928300+esafak@users.noreply.github.com> Date: Thu, 14 Aug 2025 21:33:31 -0400 Subject: [PATCH 08/17] carets may be double parsed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Emre Şafak <3928300+esafak@users.noreply.github.com> --- src/virtualenv/activation/batch/activate.bat | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/virtualenv/activation/batch/activate.bat b/src/virtualenv/activation/batch/activate.bat index 85f6aff0f..6c923bf10 100644 --- a/src/virtualenv/activation/batch/activate.bat +++ b/src/virtualenv/activation/batch/activate.bat @@ -11,7 +11,7 @@ @if NOT DEFINED VIRTUAL_ENV_PROMPT ( @for %%d in ("%VIRTUAL_ENV%") do @set "VIRTUAL_ENV_PROMPT=%%~nxd" ) -@set "VIRTUAL_ENV_PROMPT=%VIRTUAL_ENV_PROMPT:&=^&%" +@set "VIRTUAL_ENV_PROMPT=%VIRTUAL_ENV_PROMPT:&=^^&%" @if defined _OLD_VIRTUAL_PROMPT ( @set "PROMPT=%_OLD_VIRTUAL_PROMPT%" From 4e88b469273fb83c0a85c677523c6d4791054b2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emre=20=C5=9Eafak?= <3928300+esafak@users.noreply.github.com> Date: Thu, 14 Aug 2025 21:53:59 -0400 Subject: [PATCH 09/17] debugging time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Emre Şafak <3928300+esafak@users.noreply.github.com> --- .github/workflows/check.yaml | 20 ++++++++++---------- src/virtualenv/activation/batch/activate.bat | 4 ++++ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 3f943853f..928f71bd3 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -23,19 +23,19 @@ jobs: py: - "3.13t" - "3.13" - - "3.12" - - "3.11" - - "3.10" - - "3.9" +# - "3.12" +# - "3.11" +# - "3.10" +# - "3.9" - "3.8" - pypy-3.11 - - pypy-3.10 - - pypy-3.9 - - pypy-3.8 - - graalpy-24.1 +# - pypy-3.10 +# - pypy-3.9 +# - pypy-3.8 +# - graalpy-24.1 os: - - ubuntu-24.04 - - macos-15 +# - ubuntu-24.04 +# - macos-15 - windows-2025 include: - { os: macos-15, py: "brew@3.11" } diff --git a/src/virtualenv/activation/batch/activate.bat b/src/virtualenv/activation/batch/activate.bat index 6c923bf10..e768bd503 100644 --- a/src/virtualenv/activation/batch/activate.bat +++ b/src/virtualenv/activation/batch/activate.bat @@ -11,7 +11,11 @@ @if NOT DEFINED VIRTUAL_ENV_PROMPT ( @for %%d in ("%VIRTUAL_ENV%") do @set "VIRTUAL_ENV_PROMPT=%%~nxd" ) +@echo DEBUG_BEFORE_ESCAPE: "%VIRTUAL_ENV_PROMPT%" @set "VIRTUAL_ENV_PROMPT=%VIRTUAL_ENV_PROMPT:&=^^&%" +@echo DEBUG_AFTER_ESCAPE: "%VIRTUAL_ENV_PROMPT%" +@echo DEBUG_PROMPT_SET: "(%VIRTUAL_ENV_PROMPT%) %PROMPT%" +@echo DEBUG_PROMPT_ENV: "%PROMPT%" @if defined _OLD_VIRTUAL_PROMPT ( @set "PROMPT=%_OLD_VIRTUAL_PROMPT%" From 573f6ecd7a040c3f5234590c420b0433e3801a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emre=20=C5=9Eafak?= <3928300+esafak@users.noreply.github.com> Date: Thu, 14 Aug 2025 22:25:16 -0400 Subject: [PATCH 10/17] strip special chars in BatchActivator.quote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Emre Şafak <3928300+esafak@users.noreply.github.com> --- src/virtualenv/activation/batch/__init__.py | 3 ++- src/virtualenv/activation/batch/activate.bat | 5 ----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/virtualenv/activation/batch/__init__.py b/src/virtualenv/activation/batch/__init__.py index 3d74ba835..89a6eb012 100644 --- a/src/virtualenv/activation/batch/__init__.py +++ b/src/virtualenv/activation/batch/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import re from virtualenv.activation.via_template import ViaTemplateActivator @@ -17,7 +18,7 @@ def templates(self): @staticmethod def quote(string): - return string + return re.sub(r"[&<>|^]", "", string) def instantiate_template(self, replacements, template, creator): # ensure the text has all newlines as \r\n - required by batch diff --git a/src/virtualenv/activation/batch/activate.bat b/src/virtualenv/activation/batch/activate.bat index e768bd503..8997a192e 100644 --- a/src/virtualenv/activation/batch/activate.bat +++ b/src/virtualenv/activation/batch/activate.bat @@ -11,11 +11,6 @@ @if NOT DEFINED VIRTUAL_ENV_PROMPT ( @for %%d in ("%VIRTUAL_ENV%") do @set "VIRTUAL_ENV_PROMPT=%%~nxd" ) -@echo DEBUG_BEFORE_ESCAPE: "%VIRTUAL_ENV_PROMPT%" -@set "VIRTUAL_ENV_PROMPT=%VIRTUAL_ENV_PROMPT:&=^^&%" -@echo DEBUG_AFTER_ESCAPE: "%VIRTUAL_ENV_PROMPT%" -@echo DEBUG_PROMPT_SET: "(%VIRTUAL_ENV_PROMPT%) %PROMPT%" -@echo DEBUG_PROMPT_ENV: "%PROMPT%" @if defined _OLD_VIRTUAL_PROMPT ( @set "PROMPT=%_OLD_VIRTUAL_PROMPT%" From 5ca384d6d9452e07e75579ecbddb128ff00acbe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emre=20=C5=9Eafak?= <3928300+esafak@users.noreply.github.com> Date: Thu, 14 Aug 2025 22:53:03 -0400 Subject: [PATCH 11/17] Sanitize only the VIRTUAL_ENV_PROMPT placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Emre Şafak <3928300+esafak@users.noreply.github.com> --- src/virtualenv/activation/batch/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/virtualenv/activation/batch/__init__.py b/src/virtualenv/activation/batch/__init__.py index 89a6eb012..bbc0e14b8 100644 --- a/src/virtualenv/activation/batch/__init__.py +++ b/src/virtualenv/activation/batch/__init__.py @@ -18,11 +18,17 @@ def templates(self): @staticmethod def quote(string): - return re.sub(r"[&<>|^]", "", string) + return string def instantiate_template(self, replacements, template, creator): # ensure the text has all newlines as \r\n - required by batch base = super().instantiate_template(replacements, template, creator) + if template == "activate.bat": + # only sanitize the prompt placeholder + # __VIRTUAL_PROMPT__ → VIRTUAL_ENV_PROMPT + safe = replacements["__VIRTUAL_PROMPT__"] + safe = re.sub(r"(? Date: Thu, 14 Aug 2025 23:19:18 -0400 Subject: [PATCH 12/17] try again, sanitize before template substitution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Emre Şafak <3928300+esafak@users.noreply.github.com> --- src/virtualenv/activation/batch/__init__.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/virtualenv/activation/batch/__init__.py b/src/virtualenv/activation/batch/__init__.py index bbc0e14b8..81530c0c0 100644 --- a/src/virtualenv/activation/batch/__init__.py +++ b/src/virtualenv/activation/batch/__init__.py @@ -22,13 +22,14 @@ def quote(string): def instantiate_template(self, replacements, template, creator): # ensure the text has all newlines as \r\n - required by batch - base = super().instantiate_template(replacements, template, creator) if template == "activate.bat": - # only sanitize the prompt placeholder - # __VIRTUAL_PROMPT__ → VIRTUAL_ENV_PROMPT - safe = replacements["__VIRTUAL_PROMPT__"] - safe = re.sub(r"(?|^]", "", replacements["__VIRTUAL_ENV__"]) + safe_replacements["__VIRTUAL_PROMPT__"] = re.sub(r"[&<>|^]", "", replacements["__VIRTUAL_PROMPT__"]) + base = super().instantiate_template(safe_replacements, template, creator) + else: + base = super().instantiate_template(replacements, template, creator) return base.replace(os.linesep, "\n").replace("\n", os.linesep) From a09eac4c602f5ae31b1c63f1205da9d31321ef70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emre=20=C5=9Eafak?= <3928300+esafak@users.noreply.github.com> Date: Thu, 14 Aug 2025 23:31:15 -0400 Subject: [PATCH 13/17] debugging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Emre Şafak <3928300+esafak@users.noreply.github.com> --- src/virtualenv/activation/batch/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/virtualenv/activation/batch/__init__.py b/src/virtualenv/activation/batch/__init__.py index 81530c0c0..86c71b8d9 100644 --- a/src/virtualenv/activation/batch/__init__.py +++ b/src/virtualenv/activation/batch/__init__.py @@ -28,6 +28,12 @@ def instantiate_template(self, replacements, template, creator): safe_replacements["__VIRTUAL_ENV__"] = re.sub(r"[&<>|^]", "", replacements["__VIRTUAL_ENV__"]) safe_replacements["__VIRTUAL_PROMPT__"] = re.sub(r"[&<>|^]", "", replacements["__VIRTUAL_PROMPT__"]) base = super().instantiate_template(safe_replacements, template, creator) + # DEBUG: print what we generated + print(f"DEBUG SANITIZED VIRTUAL_ENV: {safe_replacements['__VIRTUAL_ENV__']}") + print(f"DEBUG SANITIZED VIRTUAL_PROMPT: {safe_replacements['__VIRTUAL_PROMPT__']}") + print("DEBUG FIRST 10 LINES OF ACTIVATE.BAT:") + for i, line in enumerate(base.split('\n')[:10]): + print(f" {i+1}: {line}") else: base = super().instantiate_template(replacements, template, creator) return base.replace(os.linesep, "\n").replace("\n", os.linesep) From 1750f8fbcc5b95a6e697da0adc521d4adc28e467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emre=20=C5=9Eafak?= <3928300+esafak@users.noreply.github.com> Date: Thu, 14 Aug 2025 23:48:32 -0400 Subject: [PATCH 14/17] try again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Emre Şafak <3928300+esafak@users.noreply.github.com> --- src/virtualenv/activation/batch/__init__.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/virtualenv/activation/batch/__init__.py b/src/virtualenv/activation/batch/__init__.py index 86c71b8d9..55e76a9ba 100644 --- a/src/virtualenv/activation/batch/__init__.py +++ b/src/virtualenv/activation/batch/__init__.py @@ -25,15 +25,18 @@ def instantiate_template(self, replacements, template, creator): if template == "activate.bat": # sanitize batch-special chars from key replacements safe_replacements = replacements.copy() - safe_replacements["__VIRTUAL_ENV__"] = re.sub(r"[&<>|^]", "", replacements["__VIRTUAL_ENV__"]) - safe_replacements["__VIRTUAL_PROMPT__"] = re.sub(r"[&<>|^]", "", replacements["__VIRTUAL_PROMPT__"]) + # Sanitize ONLY the prompt (with_prompt case) + safe_replacements["__VIRTUAL_PROMPT__"] = re.sub( + r"[&<>|^]", "", replacements["__VIRTUAL_PROMPT__"] + ) + + # For no_prompt case, sanitize the fallback name + if not safe_replacements["__VIRTUAL_PROMPT__"]: + safe_name = re.sub( + r"[&<>|^]", "", os.path.basename(replacements["__VIRTUAL_ENV__"]) + ) + safe_replacements["__VIRTUAL_PROMPT__"] = safe_name base = super().instantiate_template(safe_replacements, template, creator) - # DEBUG: print what we generated - print(f"DEBUG SANITIZED VIRTUAL_ENV: {safe_replacements['__VIRTUAL_ENV__']}") - print(f"DEBUG SANITIZED VIRTUAL_PROMPT: {safe_replacements['__VIRTUAL_PROMPT__']}") - print("DEBUG FIRST 10 LINES OF ACTIVATE.BAT:") - for i, line in enumerate(base.split('\n')[:10]): - print(f" {i+1}: {line}") else: base = super().instantiate_template(replacements, template, creator) return base.replace(os.linesep, "\n").replace("\n", os.linesep) From 4563e2ee2ca4a62be343648bca61378d5873adc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emre=20=C5=9Eafak?= <3928300+esafak@users.noreply.github.com> Date: Fri, 15 Aug 2025 00:05:05 -0400 Subject: [PATCH 15/17] Escape single quotes in paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Emre Şafak <3928300+esafak@users.noreply.github.com> --- .github/workflows/check.yaml | 8 ++++---- src/virtualenv/activation/batch/__init__.py | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 928f71bd3..923ceb8ae 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -37,10 +37,10 @@ jobs: # - ubuntu-24.04 # - macos-15 - windows-2025 - include: - - { os: macos-15, py: "brew@3.11" } - - { os: macos-15, py: "brew@3.10" } - - { os: macos-15, py: "brew@3.9" } +# include: +# - { os: macos-15, py: "brew@3.11" } +# - { os: macos-15, py: "brew@3.10" } +# - { os: macos-15, py: "brew@3.9" } exclude: - { os: windows-2025, py: "graalpy-24.1" } - { os: windows-2025, py: "pypy-3.10" } diff --git a/src/virtualenv/activation/batch/__init__.py b/src/virtualenv/activation/batch/__init__.py index 55e76a9ba..419345109 100644 --- a/src/virtualenv/activation/batch/__init__.py +++ b/src/virtualenv/activation/batch/__init__.py @@ -25,17 +25,17 @@ def instantiate_template(self, replacements, template, creator): if template == "activate.bat": # sanitize batch-special chars from key replacements safe_replacements = replacements.copy() + + # Escape ' as ^' for batch quoted paths (critical!) + safe_replacements["__VIRTUAL_ENV__"] = ( + replacements["__VIRTUAL_ENV__"].replace("'", "^'") + ) + # Sanitize ONLY the prompt (with_prompt case) safe_replacements["__VIRTUAL_PROMPT__"] = re.sub( r"[&<>|^]", "", replacements["__VIRTUAL_PROMPT__"] ) - # For no_prompt case, sanitize the fallback name - if not safe_replacements["__VIRTUAL_PROMPT__"]: - safe_name = re.sub( - r"[&<>|^]", "", os.path.basename(replacements["__VIRTUAL_ENV__"]) - ) - safe_replacements["__VIRTUAL_PROMPT__"] = safe_name base = super().instantiate_template(safe_replacements, template, creator) else: base = super().instantiate_template(replacements, template, creator) From 364e58a958ce80dba7b818e4ced908adc1aac519 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emre=20=C5=9Eafak?= <3928300+esafak@users.noreply.github.com> Date: Fri, 15 Aug 2025 00:18:28 -0400 Subject: [PATCH 16/17] remove the ' escaping for VIRTUAL_ENV and only sanitize the PROMPT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Emre Şafak <3928300+esafak@users.noreply.github.com> --- src/virtualenv/activation/batch/__init__.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/virtualenv/activation/batch/__init__.py b/src/virtualenv/activation/batch/__init__.py index 419345109..4c8e7c687 100644 --- a/src/virtualenv/activation/batch/__init__.py +++ b/src/virtualenv/activation/batch/__init__.py @@ -26,16 +26,19 @@ def instantiate_template(self, replacements, template, creator): # sanitize batch-special chars from key replacements safe_replacements = replacements.copy() - # Escape ' as ^' for batch quoted paths (critical!) - safe_replacements["__VIRTUAL_ENV__"] = ( - replacements["__VIRTUAL_ENV__"].replace("'", "^'") - ) - - # Sanitize ONLY the prompt (with_prompt case) + # ONLY sanitize the PROMPT (never touch the path!) safe_replacements["__VIRTUAL_PROMPT__"] = re.sub( - r"[&<>|^]", "", replacements["__VIRTUAL_PROMPT__"] + r"[&<>|^]", "", # Remove batch command separators + replacements["__VIRTUAL_PROMPT__"] ) + # Critical: Handle no_prompt case where PROMPT = folder name + if not safe_replacements["__VIRTUAL_PROMPT__"]: + safe_replacements["__VIRTUAL_PROMPT__"] = re.sub( + r"[&<>|^]", "", + os.path.basename(replacements["__VIRTUAL_ENV__"]) + ) + base = super().instantiate_template(safe_replacements, template, creator) else: base = super().instantiate_template(replacements, template, creator) From 14a915d08ebbad165b0c66ec3d1eaab1e12846b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emre=20=C5=9Eafak?= <3928300+esafak@users.noreply.github.com> Date: Fri, 15 Aug 2025 00:32:49 -0400 Subject: [PATCH 17/17] take two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Emre Şafak <3928300+esafak@users.noreply.github.com> --- src/virtualenv/activation/batch/__init__.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/virtualenv/activation/batch/__init__.py b/src/virtualenv/activation/batch/__init__.py index 4c8e7c687..f668bcc38 100644 --- a/src/virtualenv/activation/batch/__init__.py +++ b/src/virtualenv/activation/batch/__init__.py @@ -26,18 +26,15 @@ def instantiate_template(self, replacements, template, creator): # sanitize batch-special chars from key replacements safe_replacements = replacements.copy() - # ONLY sanitize the PROMPT (never touch the path!) - safe_replacements["__VIRTUAL_PROMPT__"] = re.sub( - r"[&<>|^]", "", # Remove batch command separators - replacements["__VIRTUAL_PROMPT__"] + # CRITICAL: Escape & in PATH assignments (batch-safe) + safe_replacements["__VIRTUAL_ENV__"] = ( + replacements["__VIRTUAL_ENV__"].replace("&", "^&") ) - # Critical: Handle no_prompt case where PROMPT = folder name - if not safe_replacements["__VIRTUAL_PROMPT__"]: - safe_replacements["__VIRTUAL_PROMPT__"] = re.sub( - r"[&<>|^]", "", - os.path.basename(replacements["__VIRTUAL_ENV__"]) - ) + # Sanitize prompt (remove batch command separators) + safe_replacements["__VIRTUAL_PROMPT__"] = re.sub( + r"[&<>|^]", "", replacements["__VIRTUAL_PROMPT__"] + ) base = super().instantiate_template(safe_replacements, template, creator) else: