spack edit: use execv instead of Executable (#11245)

- `spack edit` previously used `spack.util.executable` `Executable` objects,
  and didn't `exec` the editor like you'd expect it to

- This meant that Spack was still running while your editor was, and
 stdout/stdin were being set up in weird ways

- e.g. on macOS, if you call `spack edit` with `EDITOR` set to the
  builtin `emacs` command, then type `Ctrl-g`, the whole thing dies with
  a `==> Error: Keyboard interrupt`

- Fix all this by changing spack.util.editor to use `os.execv` instead of
  Spack's `Executable` object
This commit is contained in:
Todd Gamblin 2019-04-20 20:51:45 -07:00 committed by Greg Becker
parent 5fb6145f6f
commit 0aed3bcea6
3 changed files with 261 additions and 36 deletions

View file

@ -0,0 +1,129 @@
# Copyright 2013-2019 Lawrence Livermore National Security, LLC and other
# Spack Project Developers. See the top-level COPYRIGHT file for details.
#
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
import os
import pytest
from llnl.util.filesystem import set_executable
import spack.util.editor as ed
pytestmark = pytest.mark.usefixtures('working_env')
def _make_exe(tmpdir_factory, name, contents=None):
path = str(tmpdir_factory.mktemp('%s_exe' % name).join(name))
if contents is not None:
with open(path, 'w') as f:
f.write('#!/bin/sh\n%s\n' % contents)
set_executable(path)
return path
@pytest.fixture(scope='session')
def good_exe(tmpdir_factory):
return _make_exe(tmpdir_factory, 'good', 'exit 0')
@pytest.fixture(scope='session')
def bad_exe(tmpdir_factory):
return _make_exe(tmpdir_factory, 'bad', 'exit 1')
@pytest.fixture(scope='session')
def nosuch_exe(tmpdir_factory):
return _make_exe(tmpdir_factory, 'nosuch')
@pytest.fixture(scope='session')
def vim_exe(tmpdir_factory):
return _make_exe(tmpdir_factory, 'vim', 'exit 0')
def test_find_exe_from_env_var(good_exe):
os.environ['EDITOR'] = good_exe
assert ed._find_exe_from_env_var('EDITOR') == (good_exe, [good_exe])
def test_find_exe_from_env_var_with_args(good_exe):
os.environ['EDITOR'] = good_exe + ' a b c'
assert ed._find_exe_from_env_var('EDITOR') == (
good_exe, [good_exe, 'a', 'b', 'c'])
def test_find_exe_from_env_var_bad_path(nosuch_exe):
os.environ['EDITOR'] = nosuch_exe
assert ed._find_exe_from_env_var('FOO') == (None, [])
def test_find_exe_from_env_var_no_editor():
if 'FOO' in os.environ:
os.environ.unset('FOO')
assert ed._find_exe_from_env_var('FOO') == (None, [])
def test_editor_visual(good_exe):
os.environ['VISUAL'] = good_exe
def assert_exec(exe, args):
assert exe == good_exe
assert args == [good_exe, '/path/to/file']
ed.editor('/path/to/file', _exec_func=assert_exec)
def test_editor_visual_bad(good_exe, bad_exe):
os.environ['VISUAL'] = bad_exe
os.environ['EDITOR'] = good_exe
def assert_exec(exe, args):
if exe == bad_exe:
raise OSError()
assert exe == good_exe
assert args == [good_exe, '/path/to/file']
ed.editor('/path/to/file', _exec_func=assert_exec)
def test_editor_no_visual(good_exe):
if 'VISUAL' in os.environ:
del os.environ['VISUAL']
os.environ['EDITOR'] = good_exe
def assert_exec(exe, args):
assert exe == good_exe
assert args == [good_exe, '/path/to/file']
ed.editor('/path/to/file', _exec_func=assert_exec)
def test_editor_no_visual_with_args(good_exe):
if 'VISUAL' in os.environ:
del os.environ['VISUAL']
# editor has extra args in the var (e.g., emacs -nw)
os.environ['EDITOR'] = good_exe + ' -nw --foo'
def assert_exec(exe, args):
assert exe == good_exe
assert args == [good_exe, '-nw', '--foo', '/path/to/file']
ed.editor('/path/to/file', _exec_func=assert_exec)
def test_editor_both_bad(nosuch_exe, vim_exe):
os.environ['VISUAL'] = nosuch_exe
os.environ['EDITOR'] = nosuch_exe
os.environ['PATH'] = '%s:%s' % (
os.path.dirname(vim_exe), os.environ['PATH'])
def assert_exec(exe, args):
assert exe == vim_exe
assert args == [vim_exe, '/path/to/file']
ed.editor('/path/to/file', _exec_func=assert_exec)

View file

@ -12,30 +12,120 @@
neither variable is set, we fall back to one of several common editors,
raising an EnvironmentError if we are unable to find one.
"""
import copy
import os
import shlex
from spack.util.executable import Executable, which
import llnl.util.tty as tty
_visual_exe\
= Executable(os.environ['VISUAL']) if 'VISUAL' in os.environ else None
_editor_exe\
= Executable(os.environ['EDITOR']) \
if 'EDITOR' in os.environ else which('vim', 'vi', 'emacs', 'nano')
import spack.config
from spack.util.executable import which_string
#: editors to try if VISUAL and EDITOR are not set
_default_editors = ['vim', 'vi', 'emacs', 'nano']
def _find_exe_from_env_var(var):
"""Find an executable from an environment variable.
Args:
var (str): environment variable name
Returns:
(str or None, list): executable string (or None if not found) and
arguments parsed from the env var
"""
# try to get the environment variable
exe = os.environ.get(var)
if not exe:
return None, []
# split env var into executable and args if needed
args = shlex.split(exe)
if not args:
return None, []
exe = which_string(args[0])
args = [exe] + args[1:]
return exe, args
# Invoke the user's editor.
def editor(*args, **kwargs):
if _visual_exe:
visual_kwargs = copy.copy(kwargs)
visual_kwargs['fail_on_error'] = False
_visual_exe(*args, **visual_kwargs)
if _visual_exe.returncode == 0:
return # Otherwise, fall back to EDITOR.
"""Invoke the user's editor.
if _editor_exe:
_editor_exe(*args, **kwargs)
else:
raise EnvironmentError(
'No text editor found! Please set the VISUAL and/or EDITOR '
'environment variable(s) to your preferred text editor.')
This will try to execute the following, in order:
1. $VISUAL <args> # the "visual" editor (per POSIX)
2. $EDITOR <args> # the regular editor (per POSIX)
3. some default editor (see ``_default_editors``) with <args>
If an environment variable isn't defined, it is skipped. If it
points to something that can't be executed, we'll print a
warning. And if we can't find anything that can be executed after
searching the full list above, we'll raise an error.
Arguments:
args (list of str): args to pass to editor
Optional Arguments:
_exec_func (function): invoke this function instead of ``os.execv()``
"""
# allow this to be customized for testing
_exec_func = kwargs.get('_exec_func', os.execv)
def try_exec(exe, args, var=None):
"""Try to execute an editor with execv, and warn if it fails.
Returns: (bool) False if the editor failed, ideally does not
return if ``execv`` succeeds, and ``True`` if the
``_exec_func`` does return successfully.
"""
try:
_exec_func(exe, args)
return True
except OSError as e:
if spack.config.get('config:debug'):
raise
# Show variable we were trying to use, if it's from one
if var:
exe = '$%s (%s)' % (var, exe)
tty.warn('Could not execute %s due to error:' % exe, str(e))
return False
def try_env_var(var):
"""Find an editor from an environment variable and try to exec it.
This will warn if the variable points to something is not
executable, or if there is an error when trying to exec it.
"""
if var not in os.environ:
return False
exe, editor_args = _find_exe_from_env_var(var)
if not exe:
tty.warn('$%s is not an executable:' % var, os.environ[var])
return False
full_args = editor_args + list(args)
return try_exec(exe, full_args, var)
# try standard environment variables
if try_env_var('VISUAL'):
return
if try_env_var('EDITOR'):
return
# nothing worked -- try the first default we can find don't bother
# trying them all -- if we get here and one fails, something is
# probably much more deeply wrong with the environment.
exe = which_string(*_default_editors)
if try_exec(exe, [exe] + list(args)):
return
# Fail if nothing could be found
raise EnvironmentError(
'No text editor found! Please set the VISUAL and/or EDITOR '
'environment variable(s) to your preferred text editor.')

View file

@ -224,6 +224,26 @@ def __str__(self):
return ' '.join(self.exe)
def which_string(*args, **kwargs):
"""Like ``which()``, but return a string instead of an ``Executable``."""
path = kwargs.get('path', os.environ.get('PATH', ''))
required = kwargs.get('required', False)
if isinstance(path, string_types):
path = path.split(os.pathsep)
for name in args:
for directory in path:
exe = os.path.join(directory, name)
if os.path.isfile(exe) and os.access(exe, os.X_OK):
return exe
if required:
tty.die("spack requires '%s'. Make sure it is in your path." % args[0])
return None
def which(*args, **kwargs):
"""Finds an executable in the path like command-line which.
@ -240,22 +260,8 @@ def which(*args, **kwargs):
Returns:
Executable: The first executable that is found in the path
"""
path = kwargs.get('path', os.environ.get('PATH', ''))
required = kwargs.get('required', False)
if isinstance(path, string_types):
path = path.split(os.pathsep)
for name in args:
for directory in path:
exe = os.path.join(directory, name)
if os.path.isfile(exe) and os.access(exe, os.X_OK):
return Executable(exe)
if required:
tty.die("spack requires '%s'. Make sure it is in your path." % args[0])
return None
exe = which_string(*args, **kwargs)
return Executable(exe) if exe else None
class ProcessError(spack.error.SpackError):