diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 33cb31f..490e707 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -89,6 +89,36 @@ jobs: run: | pytest -m integration tests/ -v + # The posix activate written on Windows is only useful in a shell that + # can source it, and `shell: bash` on a windows runner is git-bash - + # exactly the environment of issue #226. + git-bash: + runs-on: windows-latest + timeout-minutes: 10 + defaults: + run: + shell: bash + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: '3.14' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Run git-bash activation test + # $BASH is the shell running this step, which is the git-bash the + # test needs: `bash` from PATH would be the WSL launcher + run: | + export NODEENV_GIT_BASH="$(cygpath -w "$BASH")" + pytest -m integration -k git_bash tests/ -v + coverage: runs-on: ubuntu-latest steps: diff --git a/CHANGES b/CHANGES index 8426744..1cc1356 100644 --- a/CHANGES +++ b/CHANGES @@ -32,6 +32,9 @@ Version [unreleased] - Documented that `--mirror` takes a `file://` URL, so a local directory can serve as the download source `#193 `_ +- The posix `activate` is now written on Windows too, into "Scripts", so + git-bash and the other posix shells there can activate an environment + `#226 `_ Version 1.3.1 ------------- diff --git a/README.rst b/README.rst index 0106269..7e2cb64 100644 --- a/README.rst +++ b/README.rst @@ -99,6 +99,12 @@ Activate new environment:: $ . env/bin/activate +On Windows the environment is created in ``env\Scripts`` instead, with a +script per shell: ``activate.bat`` for cmd, ``Activate.ps1`` for +PowerShell and ``activate`` for posix shells such as git-bash:: + + $ . env/Scripts/activate + Check versions of main packages:: (env) $ node -v diff --git a/nodeenv.py b/nodeenv.py index 0e1a676..2d6c738 100644 --- a/nodeenv.py +++ b/nodeenv.py @@ -1191,7 +1191,11 @@ def install_activate(env_dir, args): Install virtual environment activation script """ if is_WIN: + # `activate` is written on Windows too, for git-bash and the other + # posix shells available there + # https://github.com/ekalinin/nodeenv/issues/226 files = { + 'activate': ACTIVATE_SH, 'activate.bat': ACTIVATE_BAT, "deactivate.bat": DEACTIVATE_BAT, "Activate.ps1": ACTIVATE_PS1 @@ -1214,7 +1218,9 @@ def install_activate(env_dir, args): if args.node == "system": files["node"] = SHIM - mod_dir = join('lib', 'node_modules') + # npm keeps the global modules next to node.exe on Windows, + # under lib/ everywhere else + mod_dir = 'Scripts/node_modules' if is_WIN else join('lib', 'node_modules') prompt = args.prompt or '(%s)' % os.path.basename(os.path.abspath(env_dir)) if args.node == "system": @@ -1238,6 +1244,10 @@ def install_activate(env_dir, args): ['cygpath', '-w', os.path.abspath(bin_dir)], show_stdout=False, in_shell=False) content = content.replace('__NPM_CONFIG_PREFIX__', cyg_bin_dir[0]) + elif is_WIN: + # npm's prefix on Windows is the directory holding node.exe + content = content.replace('__NPM_CONFIG_PREFIX__', + '$NODE_VIRTUAL_ENV/Scripts') else: content = content.replace('__NPM_CONFIG_PREFIX__', '$NODE_VIRTUAL_ENV') @@ -1796,7 +1806,7 @@ def main(): # Detect calling this file as a script case $0 in - */bin/activate ) + */bin/activate | */Scripts/activate ) echo "Do not call $0 directly. Instead source it with \`source $0\`." exit 1 ;; @@ -1825,7 +1835,7 @@ def main(): export NODE_VIRTUAL_ENV _OLD_NODE_VIRTUAL_PATH="$PATH" -PATH="$NODE_VIRTUAL_ENV/lib/node_modules/.bin:$NODE_VIRTUAL_ENV/__BIN_NAME__:$PATH" +PATH="$NODE_VIRTUAL_ENV/__MOD_NAME__/.bin:$NODE_VIRTUAL_ENV/__BIN_NAME__:$PATH" export PATH _OLD_NODE_PATH="${NODE_PATH:-}" @@ -1840,6 +1850,17 @@ def main(): export npm_config_prefix __NPM_ISOLATE__ +# Windows shells (git-bash, MSYS, Cygwin) run a native node.exe, which +# cannot read the posix paths built above: hand it the native ones. +# $PATH stays posix, that one is read by the shell itself. +case "$(uname -s 2>/dev/null)" in + CYGWIN*|MSYS*|MINGW*) + NODE_PATH="$(cygpath -w "$NODE_PATH")" + NPM_CONFIG_PREFIX="$(cygpath -w "$NPM_CONFIG_PREFIX")" + npm_config_prefix="$NPM_CONFIG_PREFIX" + ;; +esac + if [ -z "${NODE_VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then _OLD_NODE_VIRTUAL_PS1="${PS1:-}" if [ "x__NODE_VIRTUAL_PROMPT__" != x ] ; then diff --git a/tests/nodeenv_test.py b/tests/nodeenv_test.py index 4e887e2..fe4d44e 100644 --- a/tests/nodeenv_test.py +++ b/tests/nodeenv_test.py @@ -9,6 +9,7 @@ import io import os.path import pathlib +import shutil import subprocess import sys import sysconfig @@ -50,6 +51,36 @@ def _resolve_and_run(activate, command): return resolved, version +def _git_bash(): + """ + Path of the git-bash executable, or None. + + The CI job already runs inside git-bash and passes its own shell in + NODEENV_GIT_BASH. Outside it `bash` from PATH is used, unless that + is the WSL launcher shipped in System32: with no distribution + installed it answers "Windows Subsystem for Linux has no installed + distributions" and exits 1. git-bash also ships sh.exe, which + System32 does not, so bash.exe is looked for next to it as well. + """ + from_env = os.environ.get('NODEENV_GIT_BASH') + if from_env: + return from_env + + system_root = os.environ.get('SystemRoot', r'C:\Windows').lower() + candidates = [] + on_path = shutil.which('bash') + if on_path and not on_path.lower().startswith(system_root): + candidates.append(on_path) + sh = shutil.which('sh') + if sh: + candidates.append(os.path.join(os.path.dirname(sh), 'bash.exe')) + + for candidate in candidates: + if os.path.exists(candidate): + return candidate + return None + + def _inside(path, env_dir): """ Is `path` inside `env_dir`? @@ -72,8 +103,8 @@ def test_smoke(tmpdir): ]) assert os.path.exists(nenv_path) if sys.platform == 'win32': - # on Windows nodeenv installs into Scripts/ and provides - # activate.bat/Activate.ps1, there is no posix activate script + # on Windows nodeenv installs into Scripts/, the posix activate + # written there is covered by test_smoke_git_bash subprocess.check_call([ os.path.join(nenv_path, 'Scripts', 'node.exe'), '--version', ]) @@ -90,6 +121,54 @@ def test_smoke(tmpdir): assert version, '%s --version printed nothing' % command +@pytest.mark.integration +@pytest.mark.skipif( + sys.platform != 'win32', reason='git-bash only exists on Windows') +def test_smoke_git_bash(tmpdir): + """ + The posix activate written on Windows has to work from git-bash. + https://github.com/ekalinin/nodeenv/issues/226 + """ + bash = _git_bash() + assert bash, 'git-bash not found, this test would prove nothing' + + nenv_path = tmpdir.join('nenv').strpath + subprocess.check_call([ + 'coverage', 'run', '-p', + '-m', 'nodeenv', '--prebuilt', nenv_path, + ]) + + # bash reads the script from a file: passing it inline would put the + # quoting rules of two command line parsers between the test and what + # the shell ends up running. `set -x` sends a trace to stderr, which + # is only reported when the probe fails. + # node.exe and npm answer with native paths, so both can be compared + # with the environment directory as python knows it. + probe = tmpdir.join('probe.sh') + probe.write( + 'set -ex\n' + '. "%s/Scripts/activate"\n' + 'node -p "process.execPath"\n' + 'npm root -g\n' % nenv_path.replace(os.sep, '/') + ) + proc = subprocess.run( + [bash, probe.strpath.replace(os.sep, '/')], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + report = 'exit %s\n--- stdout ---\n%s\n--- stderr ---\n%s' % ( + proc.returncode, + proc.stdout.decode('utf-8', 'replace'), + proc.stderr.decode('utf-8', 'replace')) + + assert proc.returncode == 0, report + node_exe, npm_root = proc.stdout.decode('utf-8').splitlines()[-2:] + assert _inside(node_exe, nenv_path), \ + 'node resolved to %s, outside %s' % (node_exe, nenv_path) + # npm would answer with a path outside the environment if activate + # had left it a posix prefix it cannot read + assert _inside(npm_root, nenv_path), \ + 'npm root -g is %s, outside %s' % (npm_root, nenv_path) + + @pytest.mark.integration @pytest.mark.skipif(sys.platform == 'win32', reason='-n system is posix only') def test_smoke_n_system_special_chars(tmpdir): diff --git a/tests/test_install_activate.py b/tests/test_install_activate.py index 78d1967..11b0642 100644 --- a/tests/test_install_activate.py +++ b/tests/test_install_activate.py @@ -412,6 +412,116 @@ def test_isolate_npm_shim_content(tmpdir): assert content.index('npm_config_cache') < content.index('exec ') +# Windows also gets the posix `activate`, for git-bash and friends. +# https://github.com/ekalinin/nodeenv/issues/226 +# +# These run on every platform: is_WIN is faked so the Scripts/ layout can +# be checked without a Windows host. + + +@pytest.fixture +def fake_win(): + """ + Pretend the host is Windows. install_activate() links nodejs.exe with + mklink there, which exists on Windows only, so callit() is stubbed too. + """ + with mock.patch.object(nodeenv, 'is_WIN', True): + with mock.patch.object(nodeenv, 'callit'): + yield + + +def _install_win(tmpdir, *extra_args): + bin_dir = tmpdir.join('Scripts') + if not bin_dir.check(): + bin_dir.mkdir() + + argv = ['nodeenv'] + list(extra_args) + [str(tmpdir)] + with mock.patch.object(sys, 'argv', argv): + opts = nodeenv.parse_args() + nodeenv.install_activate(str(tmpdir), opts) + return bin_dir + + +def test_win_writes_posix_activate(tmpdir, fake_win): + bin_dir = _install_win(tmpdir) + + assert sorted(p.basename for p in bin_dir.listdir()) == [ + 'Activate.ps1', 'activate', 'activate.bat', 'deactivate.bat'] + + +def test_win_activate_puts_scripts_on_path(tmpdir, fake_win): + content = _install_win(tmpdir).join('activate').read() + + assert ('PATH="$NODE_VIRTUAL_ENV/Scripts/node_modules/.bin:' + '$NODE_VIRTUAL_ENV/Scripts:$PATH"') in content + + +def test_win_activate_points_node_at_scripts(tmpdir, fake_win): + # npm keeps the global modules next to node.exe on Windows, there is + # no lib/node_modules there + content = _install_win(tmpdir).join('activate').read() + + assert 'NODE_PATH="$NODE_VIRTUAL_ENV/Scripts/node_modules"' in content + assert 'NPM_CONFIG_PREFIX="$NODE_VIRTUAL_ENV/Scripts"' in content + assert 'npm_config_prefix="$NODE_VIRTUAL_ENV/Scripts"' in content + + +def test_win_activate_has_no_placeholders_left(tmpdir, fake_win): + content = _install_win(tmpdir).join('activate').read() + + for placeholder in ('__NODE_VIRTUAL_PROMPT__', '__NODE_VIRTUAL_ENV__', + '__SHIM_NODE__', '__BIN_NAME__', '__MOD_NAME__', + '__NPM_ISOLATE__', '__NPM_UNISOLATE__', + '__NPM_CONFIG_PREFIX__'): + assert placeholder not in content + + +def test_win_activate_converts_paths_for_node_exe(tmpdir, fake_win): + # node.exe is a native binary: it cannot read the /c/... paths a + # Windows shell hands out, so the script converts them back + content = _install_win(tmpdir).join('activate').read() + + assert 'CYGWIN*|MSYS*|MINGW*)' in content + assert 'NODE_PATH="$(cygpath -w "$NODE_PATH")"' in content + assert 'NPM_CONFIG_PREFIX="$(cygpath -w "$NPM_CONFIG_PREFIX")"' in content + # the conversion must come after the variables are built + assert content.index('NODE_PATH="$NODE_VIRTUAL_ENV') < \ + content.index('cygpath -w') + + +def test_win_activate_is_valid_sh(tmpdir, fake_win): + activate = _install_win(tmpdir).join('activate') + + subprocess.check_call(['sh', '-n', str(activate)]) + + +def test_win_activate_refuses_to_be_run_directly(tmpdir, fake_win): + # the guard matches on $0, and a shell reports the path it was given: + # from a posix shell on Windows that is the forward slash form + activate = str(_install_win(tmpdir).join('activate')).replace(os.sep, '/') + + proc = subprocess.Popen( + ['sh', activate], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + out, _ = proc.communicate() + + assert proc.returncode == 1 + assert b'Do not call' in out + + +def test_win_python_virtualenv_appends_to_activate(tmpdir, fake_win): + # nodeenv -p inside a python venv: venv wrote Scripts/activate for + # git-bash already, nodeenv has to extend it, not replace it + bin_dir = tmpdir.join('Scripts') + bin_dir.mkdir() + bin_dir.join('activate').write('# python venv activate\n') + + _install_win(tmpdir, '-p') + + content = bin_dir.join('activate').read() + assert content.startswith('# python venv activate\n') + assert 'NODE_VIRTUAL_ENV_DISABLE_PROMPT=1' in content + + @pytest.mark.skipif(nodeenv.is_WIN, reason='system node is POSIX only') def test_isolate_npm_node_system_shim_exports(tmpdir): bin_dir = tmpdir.join('bin')