Skip to content

Commit

Permalink
Implement our own subshell logic
Browse files Browse the repository at this point in the history
To replace Pew's "workon" command. New code built on Shellingham.

This also fixes a few additional minor bugs, e.g. Cmder not launching
Powershell when it should.
  • Loading branch information
uranusjr committed Jun 16, 2018
1 parent e326ce6 commit 9522c7c
Show file tree
Hide file tree
Showing 3 changed files with 201 additions and 83 deletions.
113 changes: 32 additions & 81 deletions pipenv/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -720,7 +720,7 @@ def do_install_dependencies(
verbose=False,
concurrent=True,
requirements_dir=None,
pypi_mirror = False,
pypi_mirror=False,
):
""""Executes the install functionality.
Expand Down Expand Up @@ -1006,7 +1006,7 @@ def do_lock(
pre=False,
keep_outdated=False,
write=True,
pypi_mirror = None,
pypi_mirror=None,
):
"""Executes the freeze functionality."""
from .utils import get_vcs_deps
Expand Down Expand Up @@ -1169,7 +1169,7 @@ def do_lock(
return lockfile


def activate_virtualenv(source=True):
def get_virtualenv_activate_script(source=True):
"""Returns the string to activate a virtualenv."""
# Suffix and source command for other shells.
suffix = ''
Expand All @@ -1187,7 +1187,6 @@ def activate_virtualenv(source=True):
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)

Expand Down Expand Up @@ -1381,7 +1380,7 @@ def pip_install(
selective_upgrade=False,
requirements_dir=None,
extra_indexes=None,
pypi_mirror = None,
pypi_mirror=None,
):
from notpip._internal import logger as piplogger
from notpip._vendor.pyparsing import ParseException
Expand Down Expand Up @@ -2156,87 +2155,39 @@ def do_shell(three=None, python=False, fancy=False, shell_args=None):
ensure_project(three=three, python=python, validate=False)
# 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)

def fork_fancy():
shell.fork(
project.virtualenv_location,
project.project_directory,
shell_args,
)

if fancy:
fork_fancy()
return

try:
shell.fork_compat(
project.virtualenv_location,
project.project_directory,
shell_args,
get_virtualenv_activate_script(),
)
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)
fork_fancy()


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')
170 changes: 168 additions & 2 deletions pipenv/shells.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import collections
import contextlib
import os
import signal
import subprocess
import sys

from .environments import PIPENV_SHELL_EXPLICIT, PIPENV_SHELL
from .vendor import shellingham
from .environments import (
PIPENV_SHELL_EXPLICIT, PIPENV_SHELL, PIPENV_EMULATOR,
)
from .vendor import pathlib2 as pathlib, shellingham


class ShellDetectionFailure(shellingham.ShellDetectionFailure):
Expand All @@ -21,3 +28,162 @@ def detect_info():
if PIPENV_SHELL:
return _build_info(PIPENV_SHELL)
raise ShellDetectionFailure


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):
yield

def fork(self, venv, cwd, args):
# TODO: 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, lines):
from ._compat import get_terminal_size
from .utils import temp_environ
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(lines)
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 = [
pathlib.Path('bin', 'python'),
pathlib.Path('Scripts', 'python.exe'),
]


def _iter_python(venv):
for path in POSSIBLE_ENV_PYTHON:
full_path = venv.joinpath(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 = pathlib.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)

0 comments on commit 9522c7c

Please sign in to comment.