Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement our own subshell logic #2371

Merged
merged 7 commits into from
Jun 29, 2018
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
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