-
-
Notifications
You must be signed in to change notification settings - Fork 291
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Normalize and edify subprocess execution. (#255)
- Add a new pex.executor.Executor class for normalized subprocess execution. - Add new structured exception types for known failure modes of subprocess execution to provide actionable information to the end users on failure. - Port over all known library usages of subprocess to pex.executor.Executor. - Lightweight manual integration testing with pants master consuming pex master via local whl resolves.
- Loading branch information
Showing
8 changed files
with
267 additions
and
56 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
# Copyright 2016 Pants project contributors (see CONTRIBUTORS.md). | ||
# Licensed under the Apache License, Version 2.0 (see LICENSE). | ||
|
||
import errno | ||
import subprocess | ||
|
||
from .compatibility import string | ||
|
||
|
||
class Executor(object): | ||
"""Handles execution of subprocesses in a structured way.""" | ||
|
||
class ExecutionError(Exception): | ||
"""Indicates failure to execute.""" | ||
|
||
def __init__(self, msg, cmd): | ||
super(Executor.ExecutionError, self).__init__(msg) # noqa | ||
self.executable = cmd.split()[0] if isinstance(cmd, string) else cmd[0] | ||
self.cmd = cmd | ||
|
||
class NonZeroExit(ExecutionError): | ||
"""Indicates a non-zero exit code.""" | ||
|
||
def __init__(self, cmd, exit_code, stdout, stderr): | ||
super(Executor.NonZeroExit, self).__init__( # noqa | ||
'received exit code %s during execution of `%s`' % (exit_code, cmd), | ||
cmd | ||
) | ||
self.exit_code = exit_code | ||
self.stdout = stdout | ||
self.stderr = stderr | ||
|
||
class ExecutableNotFound(ExecutionError): | ||
"""Indicates the executable was not found while attempting to execute.""" | ||
|
||
def __init__(self, cmd, exc): | ||
super(Executor.ExecutableNotFound, self).__init__( # noqa | ||
'caught %r while trying to execute `%s`' % (exc, cmd), | ||
cmd | ||
) | ||
self.exc = exc | ||
|
||
@classmethod | ||
def open_process(cls, cmd, env=None, cwd=None, combined=False, **kwargs): | ||
"""Opens a process object via subprocess.Popen(). | ||
:param string|list cmd: A list or string representing the command to run. | ||
:param dict env: An environment dict for the execution. | ||
:param string cwd: The target cwd for command execution. | ||
:param bool combined: Whether or not to combine stdin and stdout streams. | ||
:return: A `subprocess.Popen` object. | ||
:raises: `Executor.ExecutableNotFound` when the executable requested to run does not exist. | ||
""" | ||
assert len(cmd) > 0, 'cannot execute an empty command!' | ||
|
||
try: | ||
return subprocess.Popen( | ||
cmd, | ||
stdin=kwargs.pop('stdin', subprocess.PIPE), | ||
stdout=kwargs.pop('stdout', subprocess.PIPE), | ||
stderr=kwargs.pop('stderr', subprocess.STDOUT if combined else subprocess.PIPE), | ||
cwd=cwd, | ||
env=env, | ||
**kwargs | ||
) | ||
except (IOError, OSError) as e: | ||
if e.errno == errno.ENOENT: | ||
raise cls.ExecutableNotFound(cmd, e) | ||
|
||
@classmethod | ||
def execute(cls, cmd, env=None, cwd=None, stdin_payload=None, **kwargs): | ||
"""Execute a command via subprocess.Popen and returns the stdio. | ||
:param string|list cmd: A list or string representing the command to run. | ||
:param dict env: An environment dict for the execution. | ||
:param string cwd: The target cwd for command execution. | ||
:param string stdin_payload: A string representing the stdin payload, if any, to send. | ||
:return: A tuple of strings representing (stdout, stderr), pre-decoded for utf-8. | ||
:raises: `Executor.ExecutableNotFound` when the executable requested to run does not exist. | ||
`Executor.NonZeroExit` when the execution fails with a non-zero exit code. | ||
""" | ||
process = cls.open_process(cmd=cmd, env=env, cwd=cwd, **kwargs) | ||
stdout_raw, stderr_raw = process.communicate(input=stdin_payload) | ||
# N.B. In cases where `stdout` or `stderr` is passed as parameters, these can be None. | ||
stdout = stdout_raw.decode('utf-8') if stdout_raw is not None else stdout_raw | ||
stderr = stderr_raw.decode('utf-8') if stderr_raw is not None else stderr_raw | ||
|
||
if process.returncode != 0: | ||
raise cls.NonZeroExit(cmd, process.returncode, stdout, stderr) | ||
|
||
return stdout, stderr |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
# Copyright 2016 Pants project contributors (see CONTRIBUTORS.md). | ||
# Licensed under the Apache License, Version 2.0 (see LICENSE). | ||
|
||
import os | ||
|
||
import pytest | ||
from twitter.common.contextutil import temporary_dir | ||
|
||
from pex.executor import Executor | ||
|
||
|
||
TEST_EXECUTABLE = '/a/nonexistent/path/to/nowhere' | ||
TEST_CMD_LIST = [TEST_EXECUTABLE, '--version'] | ||
TEST_CMD_STR = ' '.join(TEST_CMD_LIST) | ||
TEST_CMD_PARAMETERS = [TEST_CMD_LIST, TEST_CMD_STR] | ||
TEST_STDOUT = 'testing stdout' | ||
TEST_STDERR = 'testing stder' | ||
TEST_CODE = 3 | ||
|
||
|
||
def test_executor_open_process_wait_return(): | ||
process = Executor.open_process('exit 8', shell=True) | ||
exit_code = process.wait() | ||
assert exit_code == 8 | ||
|
||
|
||
def test_executor_open_process_communicate(): | ||
process = Executor.open_process(['/bin/echo', '-n', 'hello']) | ||
stdout, stderr = process.communicate() | ||
assert stdout.decode('utf-8') == 'hello' | ||
assert stderr.decode('utf-8') == '' | ||
|
||
|
||
def test_executor_execute(): | ||
assert Executor.execute('/bin/echo -n stdout >&1', shell=True) == ('stdout', '') | ||
assert Executor.execute('/bin/echo -n stderr >&2', shell=True) == ('', 'stderr') | ||
assert Executor.execute(['/bin/echo', 'hello']) == ('hello\n', '') | ||
assert Executor.execute(['/bin/echo', '-n', 'hello']) == ('hello', '') | ||
assert Executor.execute('/bin/echo -n $HELLO', env={'HELLO': 'hey'}, shell=True) == ('hey', '') | ||
|
||
|
||
def test_executor_execute_zero(): | ||
Executor.execute('exit 0', shell=True) | ||
|
||
|
||
def test_executor_execute_stdio(): | ||
with temporary_dir() as tmp: | ||
with open(os.path.join(tmp, 'stdout'), 'w+b') as fake_stdout: | ||
with open(os.path.join(tmp, 'stderr'), 'w+b') as fake_stderr: | ||
Executor.execute('/bin/echo -n TEST | tee /dev/stderr', | ||
shell=True, | ||
stdout=fake_stdout, | ||
stderr=fake_stderr) | ||
fake_stdout.seek(0) | ||
fake_stderr.seek(0) | ||
assert fake_stdout.read().decode('utf-8') == 'TEST' | ||
assert fake_stderr.read().decode('utf-8') == 'TEST' | ||
|
||
|
||
@pytest.mark.parametrize('testable', [Executor.open_process, Executor.execute]) | ||
def test_executor_execute_not_found(testable): | ||
with pytest.raises(Executor.ExecutableNotFound) as exc: | ||
testable(TEST_CMD_LIST) | ||
assert exc.value.executable == TEST_EXECUTABLE | ||
assert exc.value.cmd == TEST_CMD_LIST | ||
|
||
|
||
@pytest.mark.parametrize('exit_code', [1, 127, -1]) | ||
def test_executor_execute_nonzero(exit_code): | ||
with pytest.raises(Executor.NonZeroExit) as exc: | ||
Executor.execute('exit %s' % exit_code, shell=True) | ||
|
||
if exit_code > 0: | ||
assert exc.value.exit_code == exit_code | ||
|
||
|
||
@pytest.mark.parametrize('cmd', TEST_CMD_PARAMETERS) | ||
def test_executor_exceptions_executablenotfound(cmd): | ||
exc_cause = OSError('test') | ||
exc = Executor.ExecutableNotFound(cmd=cmd, exc=exc_cause) | ||
assert exc.executable == TEST_EXECUTABLE | ||
assert exc.cmd == cmd | ||
assert exc.exc == exc_cause | ||
|
||
|
||
@pytest.mark.parametrize('cmd', TEST_CMD_PARAMETERS) | ||
def test_executor_exceptions_nonzeroexit(cmd): | ||
exc = Executor.NonZeroExit(cmd=cmd, exit_code=TEST_CODE, stdout=TEST_STDOUT, stderr=TEST_STDERR) | ||
assert exc.executable == TEST_EXECUTABLE | ||
assert exc.cmd == cmd | ||
assert exc.exit_code == TEST_CODE | ||
assert exc.stdout == TEST_STDOUT | ||
assert exc.stderr == TEST_STDERR |
Oops, something went wrong.