Skip to content

Commit bb01555

Browse files
authored
fix(nodeenv): let -p target a virtualenv nodeenv isn't installed in (#402)
* fix(nodeenv): let -p target a virtualenv nodeenv isn't installed in -p now takes an optional directory, so a pipx/pipsi/uv tool installation can set up node.js in any python virtualenv. Without an argument the activated VIRTUAL_ENV is preferred over sys.prefix: when nodeenv lives in its own virtualenv, sys.prefix points at that virtualenv instead of the activated one. Closes #156 * fix(nodeenv): warn when -p picks the activated virtualenv over its own The activated VIRTUAL_ENV and the virtualenv nodeenv is installed in can only differ when nodeenv is installed elsewhere, and then the choice is ambiguous, so log which one is used and how to override it. The three sys.prefix branches all resolved to the same value, they are folded into a single check reused by the warning.
1 parent 5383416 commit bb01555

4 files changed

Lines changed: 121 additions & 17 deletions

File tree

CHANGES

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ Version [unreleased]
1616
virtualenv `#159 <https://github.com/ekalinin/nodeenv/issues/159>`_
1717
- Repeated `-p` runs no longer duplicate the `predeactivate` hook
1818
`#159 <https://github.com/ekalinin/nodeenv/issues/159>`_
19+
- `-p` accepts an optional virtualenv directory and prefers the activated
20+
`VIRTUAL_ENV` over the virtualenv nodeenv itself is installed in
21+
`#156 <https://github.com/ekalinin/nodeenv/issues/156>`_
1922

2023
Version 1.3.1
2124
-------------

README.rst

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -276,9 +276,13 @@ Basic options
276276
``-l, --list``
277277
Lists available node.js versions.
278278

279-
``-p, --python-virtualenv``
280-
Use current python virtualenv. Running it again with the same node
281-
version does not reinstall node; pass ``--force`` to reinstall.
279+
``-p [VENV_DIR], --python-virtualenv [VENV_DIR]``
280+
Use the given python virtualenv, or the current one if no directory
281+
is given. Passing a directory is required when nodeenv lives in its
282+
own virtualenv (``pipx``, ``pipsi``, ``uv tool``) and the activated
283+
virtualenv doesn't export ``VIRTUAL_ENV``. Running it again with the
284+
same node version does not reinstall node; pass ``--force`` to
285+
reinstall.
282286

283287
``-r FILENAME, --requirements=FILENAME``
284288
Install all the packages listed in the given requirements file.

nodeenv.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -566,8 +566,9 @@ def make_parser():
566566

567567
parser.add_argument(
568568
'--python-virtualenv', '-p', dest='python_virtualenv',
569-
action='store_true', default=False,
570-
help='Use current python virtualenv')
569+
nargs='?', const=True, default=False, metavar='VENV_DIR',
570+
help='Use the given python virtualenv, or the current one '
571+
'if no directory is given')
571572

572573
parser.add_argument(
573574
'--clean-src', '-c', dest='clean_src',
@@ -1381,14 +1382,27 @@ def resolve_node_version(spec):
13811382

13821383
def get_env_dir(args):
13831384
if args.python_virtualenv:
1384-
if hasattr(sys, 'real_prefix'):
1385-
res = sys.prefix
1386-
elif hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix:
1387-
res = sys.prefix
1388-
elif 'CONDA_PREFIX' in os.environ:
1389-
res = sys.prefix
1390-
elif 'VIRTUAL_ENV' in os.environ:
1385+
# whether nodeenv itself is running inside a python virtualenv
1386+
in_virtualenv = (
1387+
hasattr(sys, 'real_prefix') or
1388+
(hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix) or
1389+
'CONDA_PREFIX' in os.environ)
1390+
if args.python_virtualenv is not True:
1391+
res = args.python_virtualenv
1392+
if not os.path.isdir(res):
1393+
logger.error("Python virtualenv '%s' doesn't exist", res)
1394+
sys.exit(2)
1395+
# nodeenv itself can be installed into its own virtualenv
1396+
# (pipx, pipsi, uv tool), so the activated one wins over sys.prefix
1397+
elif os.environ.get('VIRTUAL_ENV'):
13911398
res = os.environ['VIRTUAL_ENV']
1399+
if in_virtualenv and res != sys.prefix:
1400+
logger.warning(
1401+
' * Using activated virtualenv %s, not %s where nodeenv '
1402+
'is installed, pass a directory to -p to override',
1403+
res, sys.prefix)
1404+
elif in_virtualenv:
1405+
res = sys.prefix
13921406
else:
13931407
logger.error('No python virtualenv is available')
13941408
sys.exit(2)

tests/nodeenv_test.py

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,18 @@ def test_parse_args_prefer_system():
698698
assert nodeenv.parse_args().prefer_system is False
699699

700700

701+
def test_parse_args_python_virtualenv():
702+
with mock.patch.object(sys, 'argv', ['nodeenv', '-p']):
703+
assert nodeenv.parse_args().python_virtualenv is True
704+
with mock.patch.object(sys, 'argv', ['nodeenv', '-p', 'venv']):
705+
assert nodeenv.parse_args().python_virtualenv == 'venv'
706+
with mock.patch.object(
707+
sys, 'argv', ['nodeenv', '--python-virtualenv=venv']):
708+
assert nodeenv.parse_args().python_virtualenv == 'venv'
709+
with mock.patch.object(sys, 'argv', ['nodeenv', 'env']):
710+
assert nodeenv.parse_args().python_virtualenv is False
711+
712+
701713
def test_isolate_npm_default():
702714
assert nodeenv.Config._default['isolate_npm'] is False
703715

@@ -1622,7 +1634,8 @@ def test_with_python_virtualenv_real_prefix(self):
16221634
test_prefix = '/path/to/virtualenv'
16231635

16241636
with mock.patch.object(sys, 'real_prefix', test_prefix, create=True), \
1625-
mock.patch.object(sys, 'prefix', test_prefix):
1637+
mock.patch.object(sys, 'prefix', test_prefix), \
1638+
mock.patch.dict(os.environ, {}, clear=True):
16261639
result = nodeenv.get_env_dir(args)
16271640
assert result == test_prefix
16281641

@@ -1637,12 +1650,14 @@ def test_with_python_virtualenv_base_prefix(self):
16371650
if hasattr(sys, 'real_prefix'):
16381651
with mock.patch.object(sys, 'real_prefix', create=False):
16391652
with mock.patch.object(sys, 'prefix', test_prefix), \
1640-
mock.patch.object(sys, 'base_prefix', test_base_prefix):
1653+
mock.patch.object(sys, 'base_prefix', test_base_prefix), \
1654+
mock.patch.dict(os.environ, {}, clear=True):
16411655
result = nodeenv.get_env_dir(args)
16421656
assert result == test_prefix
16431657
else:
16441658
with mock.patch.object(sys, 'prefix', test_prefix), \
1645-
mock.patch.object(sys, 'base_prefix', test_base_prefix):
1659+
mock.patch.object(sys, 'base_prefix', test_base_prefix), \
1660+
mock.patch.dict(os.environ, {}, clear=True):
16461661
result = nodeenv.get_env_dir(args)
16471662
assert result == test_prefix
16481663

@@ -1658,14 +1673,14 @@ def test_with_python_virtualenv_conda_prefix(self):
16581673
env_dict = {'CONDA_PREFIX': test_prefix}
16591674
with mock.patch.object(sys, 'prefix', test_prefix), \
16601675
mock.patch.object(sys, 'base_prefix', test_prefix), \
1661-
mock.patch.dict(os.environ, env_dict):
1676+
mock.patch.dict(os.environ, env_dict, clear=True):
16621677
result = nodeenv.get_env_dir(args)
16631678
assert result == test_prefix
16641679
else:
16651680
env_dict = {'CONDA_PREFIX': test_prefix}
16661681
with mock.patch.object(sys, 'prefix', test_prefix), \
16671682
mock.patch.object(sys, 'base_prefix', test_prefix), \
1668-
mock.patch.dict(os.environ, env_dict):
1683+
mock.patch.dict(os.environ, env_dict, clear=True):
16691684
result = nodeenv.get_env_dir(args)
16701685
assert result == test_prefix
16711686

@@ -1716,6 +1731,74 @@ def test_with_python_virtualenv_no_virtualenv_exits(self):
17161731
nodeenv.get_env_dir(args)
17171732
assert exc_info.value.code == 2
17181733

1734+
def test_with_python_virtualenv_dir(self, tmpdir):
1735+
"""Test get_env_dir when a virtualenv directory is given"""
1736+
args = mock.Mock()
1737+
args.python_virtualenv = str(tmpdir)
1738+
1739+
env_dict = {'VIRTUAL_ENV': '/path/to/other/venv'}
1740+
with mock.patch.dict(os.environ, env_dict, clear=True):
1741+
result = nodeenv.get_env_dir(args)
1742+
assert result == str(tmpdir)
1743+
1744+
def test_with_python_virtualenv_missing_dir_exits(self, tmpdir):
1745+
"""Test get_env_dir exits when the given virtualenv doesn't exist"""
1746+
args = mock.Mock()
1747+
args.python_virtualenv = str(tmpdir.join('missing'))
1748+
1749+
with pytest.raises(SystemExit) as exc_info:
1750+
nodeenv.get_env_dir(args)
1751+
assert exc_info.value.code == 2
1752+
1753+
def test_with_python_virtualenv_prefers_virtual_env(self):
1754+
"""Test get_env_dir prefers VIRTUAL_ENV over nodeenv's own venv"""
1755+
args = mock.Mock()
1756+
args.python_virtualenv = True
1757+
# nodeenv itself is installed into its own virtualenv
1758+
test_prefix = '/path/to/nodeenv/venv'
1759+
virtual_env = '/path/to/activated/venv'
1760+
1761+
env_dict = {'VIRTUAL_ENV': virtual_env}
1762+
with mock.patch.object(sys, 'real_prefix', test_prefix, create=True), \
1763+
mock.patch.object(sys, 'prefix', test_prefix), \
1764+
mock.patch.dict(os.environ, env_dict, clear=True), \
1765+
mock.patch.object(nodeenv.logger, 'warning') as mck:
1766+
result = nodeenv.get_env_dir(args)
1767+
assert result == virtual_env
1768+
# the ignored virtualenv is not silently dropped
1769+
assert mck.call_count == 1
1770+
assert mck.call_args[0][1:] == (virtual_env, test_prefix)
1771+
1772+
def test_with_python_virtualenv_same_venv_is_quiet(self):
1773+
"""Test get_env_dir doesn't warn when both point to the same venv"""
1774+
args = mock.Mock()
1775+
args.python_virtualenv = True
1776+
test_prefix = '/path/to/venv'
1777+
1778+
env_dict = {'VIRTUAL_ENV': test_prefix}
1779+
with mock.patch.object(sys, 'real_prefix', test_prefix, create=True), \
1780+
mock.patch.object(sys, 'prefix', test_prefix), \
1781+
mock.patch.dict(os.environ, env_dict, clear=True), \
1782+
mock.patch.object(nodeenv.logger, 'warning') as mck:
1783+
result = nodeenv.get_env_dir(args)
1784+
assert result == test_prefix
1785+
mck.assert_not_called()
1786+
1787+
def test_with_python_virtualenv_system_python_is_quiet(self):
1788+
"""Test get_env_dir doesn't warn when nodeenv runs system-wide"""
1789+
args = mock.Mock()
1790+
args.python_virtualenv = True
1791+
virtual_env = '/path/to/activated/venv'
1792+
1793+
env_dict = {'VIRTUAL_ENV': virtual_env}
1794+
with mock.patch.object(sys, 'prefix', '/usr'), \
1795+
mock.patch.object(sys, 'base_prefix', '/usr'), \
1796+
mock.patch.dict(os.environ, env_dict, clear=True), \
1797+
mock.patch.object(nodeenv.logger, 'warning') as mck:
1798+
result = nodeenv.get_env_dir(args)
1799+
assert result == virtual_env
1800+
mck.assert_not_called()
1801+
17191802
def test_without_python_virtualenv(self):
17201803
"""Test get_env_dir when not using python virtualenv"""
17211804
args = mock.Mock()

0 commit comments

Comments
 (0)