Skip to content

Commit

Permalink
Merge pull request #2371 from pypa/feature/repatch-shell-detection
Browse files Browse the repository at this point in the history
Implement our own subshell logic
  • Loading branch information
techalchemy authored Jun 29, 2018
2 parents 36c8957 + 1525e16 commit 8691c71
Show file tree
Hide file tree
Showing 7 changed files with 230 additions and 119 deletions.
1 change: 1 addition & 0 deletions news/2371.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
All calls to ``pipenv shell`` are now implemented from the ground up using `shellingham <https://github.com/sarugaku/shellingham>`_, a custom library which was purpose built to handle edge cases and shell detection.
1 change: 1 addition & 0 deletions news/2371.vendor
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
All calls to ``pipenv shell`` are now implemented from the ground up using `shellingham <https://github.com/sarugaku/shellingham>`_, a custom library which was purpose built to handle edge cases and shell detection.
5 changes: 5 additions & 0 deletions pipenv/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
129 changes: 23 additions & 106 deletions pipenv/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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():
Expand Down
1 change: 1 addition & 0 deletions pipenv/environments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
201 changes: 198 additions & 3 deletions pipenv/shells.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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)
Loading

0 comments on commit 8691c71

Please sign in to comment.