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:
parent
5fb6145f6f
commit
0aed3bcea6
3 changed files with 261 additions and 36 deletions
129
lib/spack/spack/test/util/editor.py
Normal file
129
lib/spack/spack/test/util/editor.py
Normal 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)
|
|
@ -12,30 +12,120 @@
|
||||||
neither variable is set, we fall back to one of several common editors,
|
neither variable is set, we fall back to one of several common editors,
|
||||||
raising an EnvironmentError if we are unable to find one.
|
raising an EnvironmentError if we are unable to find one.
|
||||||
"""
|
"""
|
||||||
import copy
|
|
||||||
import os
|
import os
|
||||||
|
import shlex
|
||||||
|
|
||||||
from spack.util.executable import Executable, which
|
import llnl.util.tty as tty
|
||||||
|
|
||||||
_visual_exe\
|
import spack.config
|
||||||
= Executable(os.environ['VISUAL']) if 'VISUAL' in os.environ else None
|
from spack.util.executable import which_string
|
||||||
_editor_exe\
|
|
||||||
= Executable(os.environ['EDITOR']) \
|
|
||||||
if 'EDITOR' in os.environ else which('vim', 'vi', 'emacs', 'nano')
|
#: 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):
|
def editor(*args, **kwargs):
|
||||||
if _visual_exe:
|
"""Invoke the user's editor.
|
||||||
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.
|
|
||||||
|
|
||||||
if _editor_exe:
|
This will try to execute the following, in order:
|
||||||
_editor_exe(*args, **kwargs)
|
|
||||||
else:
|
1. $VISUAL <args> # the "visual" editor (per POSIX)
|
||||||
raise EnvironmentError(
|
2. $EDITOR <args> # the regular editor (per POSIX)
|
||||||
'No text editor found! Please set the VISUAL and/or EDITOR '
|
3. some default editor (see ``_default_editors``) with <args>
|
||||||
'environment variable(s) to your preferred text editor.')
|
|
||||||
|
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.')
|
||||||
|
|
|
@ -224,6 +224,26 @@ def __str__(self):
|
||||||
return ' '.join(self.exe)
|
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):
|
def which(*args, **kwargs):
|
||||||
"""Finds an executable in the path like command-line which.
|
"""Finds an executable in the path like command-line which.
|
||||||
|
|
||||||
|
@ -240,22 +260,8 @@ def which(*args, **kwargs):
|
||||||
Returns:
|
Returns:
|
||||||
Executable: The first executable that is found in the path
|
Executable: The first executable that is found in the path
|
||||||
"""
|
"""
|
||||||
path = kwargs.get('path', os.environ.get('PATH', ''))
|
exe = which_string(*args, **kwargs)
|
||||||
required = kwargs.get('required', False)
|
return Executable(exe) if exe else None
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class ProcessError(spack.error.SpackError):
|
class ProcessError(spack.error.SpackError):
|
||||||
|
|
Loading…
Reference in a new issue