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
30 changes: 30 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ Version [unreleased]
- Documented that `--mirror` takes a `file://` URL, so a local directory can
serve as the download source
`#193 <https://github.com/ekalinin/nodeenv/issues/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 <https://github.com/ekalinin/nodeenv/issues/226>`_

Version 1.3.1
-------------
Expand Down
6 changes: 6 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 24 additions & 3 deletions nodeenv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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":
Expand All @@ -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')
Expand Down Expand Up @@ -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
;;
Expand Down Expand Up @@ -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:-}"
Expand All @@ -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
Expand Down
83 changes: 81 additions & 2 deletions tests/nodeenv_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import io
import os.path
import pathlib
import shutil
import subprocess
import sys
import sysconfig
Expand Down Expand Up @@ -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`?
Expand All @@ -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',
])
Expand All @@ -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):
Expand Down
110 changes: 110 additions & 0 deletions tests/test_install_activate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading