diff --git a/news/2371.feature b/news/2371.feature new file mode 100644 index 0000000000..3d71fe0d2a --- /dev/null +++ b/news/2371.feature @@ -0,0 +1 @@ +All calls to ``pipenv shell`` are now implemented from the ground up using `shellingham `_, a custom library which was purpose built to handle edge cases and shell detection. diff --git a/news/2371.vendor b/news/2371.vendor new file mode 100644 index 0000000000..3d71fe0d2a --- /dev/null +++ b/news/2371.vendor @@ -0,0 +1 @@ +All calls to ``pipenv shell`` are now implemented from the ground up using `shellingham `_, a custom library which was purpose built to handle edge cases and shell detection. diff --git a/pipenv/_compat.py b/pipenv/_compat.py index 07e7507228..fa9e0a1d9e 100644 --- a/pipenv/_compat.py +++ b/pipenv/_compat.py @@ -37,6 +37,11 @@ def _infer_return_type(*args): else: from .vendor.pathlib2 import Path +# Backport required for earlier versions of Python. +if sys.version_info < (3, 3): + from .vendor.backports.shutil_get_terminal_size import get_terminal_size +else: + from shutil import get_terminal_size try: from weakref import finalize diff --git a/pipenv/core.py b/pipenv/core.py index 494a1efc4b..a2396cbc2d 100644 --- a/pipenv/core.py +++ b/pipenv/core.py @@ -79,11 +79,6 @@ PIPENV_CACHE_DIR, ) -# Backport required for earlier versions of Python. -if sys.version_info < (3, 3): - from .vendor.backports.shutil_get_terminal_size import get_terminal_size -else: - from shutil import get_terminal_size # Packages that should be ignored later. BAD_PACKAGES = ('setuptools', 'pip', 'wheel', 'packaging', 'distribute') # Are we using the default Python? @@ -1169,29 +1164,6 @@ def do_lock( return lockfile -def activate_virtualenv(source=True): - """Returns the string to activate a virtualenv.""" - # Suffix and source command for other shells. - suffix = '' - command = ' .' if source else '' - # Support for fish shell. - if PIPENV_SHELL and 'fish' in PIPENV_SHELL: - suffix = '.fish' - command = 'source' - # Support for csh shell. - if PIPENV_SHELL and 'csh' in PIPENV_SHELL: - suffix = '.csh' - command = 'source' - # Escape any spaces located within the virtualenv path to allow - # for proper activation. - venv_location = project.virtualenv_location.replace(' ', r'\ ') - if source: - return '{2} {0}/bin/activate{1}'.format(venv_location, suffix, command) - - else: - return '{0}/bin/activate'.format(venv_location) - - def do_purge(bare=False, downloads=False, allow_global=False, verbose=False): """Executes the purge functionality.""" if downloads: @@ -2169,94 +2141,39 @@ def do_uninstall( do_lock(system=system, keep_outdated=keep_outdated, pypi_mirror=pypi_mirror) -def do_shell(three=None, python=False, fancy=False, shell_args=None, pypi_mirror=None): - from .patched.pew import pew +def do_shell(three=None, python=False, fancy=False, shell_args=None, pypi_mirror=None): # Ensure that virtualenv is available. ensure_project(three=three, python=python, validate=False, pypi_mirror=pypi_mirror) # Set an environment variable, so we know we're in the environment. os.environ['PIPENV_ACTIVE'] = '1' - compat = (not fancy) # Support shell compatibility mode. if PIPENV_SHELL_FANCY: - compat = False - # Compatibility mode: - if compat: - if PIPENV_SHELL: - shell = os.path.abspath(PIPENV_SHELL) - else: - click.echo( - crayons.red( - 'Please ensure that the {0} environment variable ' - 'is set before activating shell.'.format( - crayons.normal('SHELL', bold=True) - ) - ), - err=True, - ) - sys.exit(1) + fancy = True + + from .shells import choose_shell + shell = choose_shell() + click.echo("Launching subshell in virtual environment…", err=True) + + fork_args = ( + project.virtualenv_location, + project.project_directory, + shell_args, + ) + + if fancy: + shell.fork(*fork_args) + return + + try: + shell.fork_compat(*fork_args) + except (AttributeError, ImportError): click.echo( - crayons.normal( - 'Spawning environment shell ({0}). Use {1} to leave.'.format( - crayons.red(shell), crayons.normal("'exit'", bold=True) - ), - bold=True, - ), + u'Compatibility mode not supported. ' + u'Trying to continue as well-configured shell…', err=True, ) - cmd = "{0} -i'".format(shell) - args = [] - # Standard (properly configured shell) mode: - else: - if project.is_venv_in_project(): - # use .venv as the target virtualenv name - workon_name = '.venv' - else: - workon_name = project.virtualenv_name - cmd = sys.executable - args = ['-m', 'pipenv.pew', 'workon', workon_name] - # Grab current terminal dimensions to replace the hardcoded default - # dimensions of pexpect - terminal_dimensions = get_terminal_size() - try: - with temp_environ(): - if project.is_venv_in_project(): - os.environ['WORKON_HOME'] = project.project_directory - c = pexpect.spawn( - cmd, - args, - dimensions=( - terminal_dimensions.lines, terminal_dimensions.columns - ), - ) - # Windows! - except AttributeError: - # import subprocess - # Tell pew to use the project directory as its workon_home - with temp_environ(): - if project.is_venv_in_project(): - os.environ['WORKON_HOME'] = project.project_directory - pew.workon_cmd([workon_name]) - sys.exit(0) - # Activate the virtualenv if in compatibility mode. - if compat: - c.sendline(activate_virtualenv()) - # Send additional arguments to the subshell. - if shell_args: - c.sendline(' '.join(shell_args)) - - # Handler for terminal resizing events - # Must be defined here to have the shell process in its context, since we - # can't pass it as an argument - def sigwinch_passthrough(sig, data): - terminal_dimensions = get_terminal_size() - c.setwinsize(terminal_dimensions.lines, terminal_dimensions.columns) - - signal.signal(signal.SIGWINCH, sigwinch_passthrough) - # Interact with the new shell. - c.interact(escape_character=None) - c.close() - sys.exit(c.exitstatus) + shell.fork(*fork_args) def inline_activate_virtualenv(): diff --git a/pipenv/environments.py b/pipenv/environments.py index 36fada8c65..3ab5eefc5b 100644 --- a/pipenv/environments.py +++ b/pipenv/environments.py @@ -76,6 +76,7 @@ SESSION_IS_INTERACTIVE = bool(os.isatty(sys.stdout.fileno())) PIPENV_SHELL_EXPLICIT = os.environ.get('PIPENV_SHELL') PIPENV_SHELL = os.environ.get('SHELL') or os.environ.get('PYENV_SHELL') +PIPENV_EMULATOR = os.environ.get('PIPENV_EMULATOR') PIPENV_CACHE_DIR = os.environ.get('PIPENV_CACHE_DIR', user_cache_dir('pipenv')) # Tells pipenv to override PyPI index urls with a mirror. PIPENV_PYPI_MIRROR = os.environ.get('PIPENV_PYPI_MIRROR') diff --git a/pipenv/shells.py b/pipenv/shells.py index 921c72ce8b..87692b2560 100644 --- a/pipenv/shells.py +++ b/pipenv/shells.py @@ -1,11 +1,17 @@ +import collections +import contextlib import os +import signal +import subprocess +import sys -from .environments import PIPENV_SHELL_EXPLICIT, PIPENV_SHELL +from ._compat import get_terminal_size, Path +from .environments import PIPENV_SHELL_EXPLICIT, PIPENV_SHELL, PIPENV_EMULATOR +from .utils import temp_environ from .vendor import shellingham -class ShellDetectionFailure(shellingham.ShellDetectionFailure): - pass +ShellDetectionFailure = shellingham.ShellDetectionFailure def _build_info(value): @@ -21,3 +27,192 @@ def detect_info(): if PIPENV_SHELL: return _build_info(PIPENV_SHELL) raise ShellDetectionFailure + + +def _get_activate_script(venv): + """Returns the string to activate a virtualenv. + + This is POSIX-only at the moment since the compat (pexpect-based) shell + does not work elsewhere anyway. + """ + # Suffix and source command for other shells. + # Support for fish shell. + if PIPENV_SHELL and 'fish' in PIPENV_SHELL: + suffix = '.fish' + command = 'source' + # Support for csh shell. + elif PIPENV_SHELL and 'csh' in PIPENV_SHELL: + suffix = '.csh' + command = 'source' + else: + suffix = '' + command = '.' + # Escape any spaces located within the virtualenv path to allow + # for proper activation. + venv_location = str(venv).replace(' ', r'\ ') + # The leading space can make history cleaner in some shells. + return ' {2} {0}/bin/activate{1}'.format(venv_location, suffix, command) + + +def _handover(cmd, args): + args = [cmd] + args + if os.name != 'nt': + os.execvp(cmd, args) + else: + proc = subprocess.run(args, shell=True, universal_newlines=True) + sys.exit(proc.returncode) + + +class Shell(object): + + def __init__(self, cmd): + self.cmd = cmd + self.args = [] + + @contextlib.contextmanager + def inject_path(self, venv): + with temp_environ(): + os.environ['PATH'] = '{0}{1}{2}'.format( + os.pathsep.join(str(p.parent) for p in _iter_python(venv)), + os.pathsep, + os.environ['PATH'], + ) + yield + + def fork(self, venv, cwd, args): + # FIXME: This isn't necessarily the correct prompt. We should read the + # actual prompt by peeking into the activation script. + name = os.path.basename(venv) + os.environ['VIRTUAL_ENV'] = str(venv) + if 'PROMPT' in os.environ: + os.environ['PROMPT'] = '({0}) {1}'.format( + name, os.environ['PROMPT'], + ) + if 'PS1' in os.environ: + os.environ['PS1'] = '({0}) {1}'.format( + name, os.environ['PS1'], + ) + with self.inject_path(venv): + os.chdir(cwd) + _handover(self.cmd, self.args + list(args)) + + def fork_compat(self, venv, cwd, args): + from .vendor import pexpect + + # Grab current terminal dimensions to replace the hardcoded default + # dimensions of pexpect. + dims = get_terminal_size() + with temp_environ(): + c = pexpect.spawn( + self.cmd, ['-i'], dimensions=(dims.lines, dims.columns), + ) + c.sendline(_get_activate_script(venv)) + if args: + c.sendline(' '.join(args)) + + # Handler for terminal resizing events + # Must be defined here to have the shell process in its context, since + # we can't pass it as an argument + def sigwinch_passthrough(sig, data): + dims = get_terminal_size() + c.setwinsize(dims.lines, dims.columns) + + signal.signal(signal.SIGWINCH, sigwinch_passthrough) + + # Interact with the new shell. + c.interact(escape_character=None) + c.close() + sys.exit(c.exitstatus) + + +POSSIBLE_ENV_PYTHON = [ + Path('bin', 'python'), + Path('Scripts', 'python.exe'), +] + + +def _iter_python(venv): + for path in POSSIBLE_ENV_PYTHON: + full_path = Path(venv, path) + if full_path.is_file(): + yield full_path + + +class Bash(Shell): + # The usual PATH injection technique does not work with Bash. + # https://github.com/berdario/pew/issues/58#issuecomment-102182346 + @contextlib.contextmanager + def inject_path(self, venv): + from ._compat import NamedTemporaryFile + bashrc_path = Path.home().joinpath('.bashrc') + with NamedTemporaryFile('w+') as rcfile: + if bashrc_path.is_file(): + base_rc_src = 'source "{0}"\n'.format(bashrc_path.as_posix()) + rcfile.write(base_rc_src) + + export_path = 'export PATH="{0}:$PATH"\n'.format(':'.join( + python.parent.as_posix() + for python in _iter_python(venv) + )) + rcfile.write(export_path) + rcfile.flush() + self.args.extend(['--rcfile', rcfile.name]) + yield + + +class CmderEmulatedShell(Shell): + def fork(self, venv, cwd, args): + if cwd: + os.environ['CMDER_START'] = cwd + super(CmderEmulatedShell, self).fork(venv, cwd, args) + + +class CmderCommandPrompt(CmderEmulatedShell): + def fork(self, venv, cwd, args): + rc = os.path.expandvars('%CMDER_ROOT%\\vendor\\init.bat') + if os.path.exists(rc): + self.args.extend(['/k', rc]) + super(CmderCommandPrompt, self).fork(venv, cwd, args) + + +class CmderPowershell(Shell): + def fork(self, venv, cwd, args): + rc = os.path.expandvars('%CMDER_ROOT%\\vendor\\profile.ps1') + if os.path.exists(rc): + self.args.extend([ + '-ExecutionPolicy', 'Bypass', '-NoLogo', '-NoProfile', + '-NoExit', '-Command', + "Invoke-Expression '. ''{0}'''".format(rc), + ]) + super(CmderPowershell, self).fork(venv, cwd, args) + + +# Two dimensional dict. First is the shell type, second is the emulator type. +# Example: SHELL_LOOKUP['powershell']['cmder'] => CmderPowershell. +SHELL_LOOKUP = collections.defaultdict( + lambda: collections.defaultdict(lambda: Shell), + { + 'bash': collections.defaultdict(lambda: Bash), + 'cmd': collections.defaultdict(lambda: Shell, { + 'cmder': CmderCommandPrompt, + }), + 'powershell': collections.defaultdict(lambda: Shell, { + 'cmder': CmderPowershell, + }), + 'pwsh': collections.defaultdict(lambda: Shell, { + 'cmder': CmderPowershell, + }), + }, +) + + +def _detect_emulator(): + if os.environ.get('CMDER_ROOT'): + return 'cmder' + return '' + + +def choose_shell(): + emulator = PIPENV_EMULATOR or _detect_emulator() + type_, command = detect_info() + return SHELL_LOOKUP[type_][emulator](command) diff --git a/tests/integration/test_pipenv.py b/tests/integration/test_pipenv.py index 89ee793ebb..1b9b66a552 100644 --- a/tests/integration/test_pipenv.py +++ b/tests/integration/test_pipenv.py @@ -2,13 +2,13 @@ XXX: Try our best to reduce tests in this file. """ + import os from tempfile import gettempdir, mkdtemp import mock import pytest -from pipenv.core import activate_virtualenv from pipenv.project import Project from pipenv.vendor import delegator from pipenv._compat import Path @@ -25,15 +25,6 @@ def test_code_import_manual(PipenvInstance): assert 'requests' in p.pipfile['packages'] -@pytest.mark.code -@pytest.mark.virtualenv -@pytest.mark.project -def test_activate_virtualenv_no_source(): - command = activate_virtualenv(source=False) - venv = Project().virtualenv_location - assert command == '{0}/bin/activate'.format(venv) - - @pytest.mark.lock @pytest.mark.deploy @pytest.mark.cli