├── .gitignore ├── .travis.yml ├── AUTHORS ├── LICENSE ├── MANIFEST.in ├── README.rst ├── envoy ├── __init__.py └── core.py ├── ext └── in_action.png ├── setup.cfg ├── setup.py └── test_envoy.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - 2.6 5 | - 2.7 6 | - pypy 7 | 8 | script: python test_envoy.py 9 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Envoy is written and maintained by Kenneth Reitz and 2 | various contributors: 3 | 4 | Development Lead 5 | ```````````````` 6 | 7 | - Kenneth Reitz 8 | 9 | 10 | Patches and Suggestions 11 | ``````````````````````` 12 | 13 | - Mark Holland 14 | - mrshu -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Kenneth Reitz 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include README.rst 3 | include test_envoy.py 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Envoy: Python Subprocesses for Humans. 2 | ====================================== 3 | 4 | **Note:** `Delegator `_ is a replacement for Envoy. 5 | 6 | This is a convenience wrapper around the `subprocess` module. 7 | 8 | You don't need this. 9 | 10 | .. image:: https://github.com/kennethreitz/envoy/raw/master/ext/in_action.png 11 | 12 | But you want it. 13 | 14 | 15 | Usage 16 | ----- 17 | 18 | Run a command, get the response:: 19 | 20 | >>> r = envoy.run('git config', data='data to pipe in', timeout=2) 21 | 22 | >>> r.status_code 23 | 129 24 | >>> r.std_out 25 | 'usage: git config [options]' 26 | >>> r.std_err 27 | '' 28 | 29 | Pipe stuff around too:: 30 | 31 | >>> r = envoy.run('uptime | pbcopy') 32 | 33 | >>> r.command 34 | 'pbcopy' 35 | >>> r.status_code 36 | 0 37 | 38 | >>> r.history 39 | [] 40 | -------------------------------------------------------------------------------- /envoy/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | from .core import Command, ConnectedCommand, Response 4 | from .core import expand_args, run, connect 5 | 6 | from .core import __version__ 7 | -------------------------------------------------------------------------------- /envoy/core.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | envoy.core 5 | ~~~~~~~~~~ 6 | 7 | This module provides envoy awesomeness. 8 | """ 9 | 10 | import os 11 | import sys 12 | import shlex 13 | import signal 14 | import subprocess 15 | import threading 16 | import traceback 17 | 18 | 19 | __version__ = '0.0.3' 20 | __license__ = 'MIT' 21 | __author__ = 'Kenneth Reitz' 22 | 23 | 24 | def _terminate_process(process): 25 | if sys.platform == 'win32': 26 | import ctypes 27 | PROCESS_TERMINATE = 1 28 | handle = ctypes.windll.kernel32.OpenProcess(PROCESS_TERMINATE, False, process.pid) 29 | ctypes.windll.kernel32.TerminateProcess(handle, -1) 30 | ctypes.windll.kernel32.CloseHandle(handle) 31 | else: 32 | os.kill(process.pid, signal.SIGTERM) 33 | 34 | 35 | def _kill_process(process): 36 | if sys.platform == 'win32': 37 | _terminate_process(process) 38 | else: 39 | os.kill(process.pid, signal.SIGKILL) 40 | 41 | 42 | def _is_alive(thread): 43 | if hasattr(thread, "is_alive"): 44 | return thread.is_alive() 45 | else: 46 | return thread.isAlive() 47 | 48 | 49 | class Command(object): 50 | def __init__(self, cmd): 51 | self.cmd = cmd 52 | self.process = None 53 | self.out = None 54 | self.err = None 55 | self.returncode = None 56 | self.data = None 57 | self.exc = None 58 | 59 | def run(self, data, timeout, kill_timeout, env, cwd): 60 | self.data = data 61 | environ = dict(os.environ) 62 | environ.update(env or {}) 63 | 64 | def target(): 65 | 66 | try: 67 | self.process = subprocess.Popen(self.cmd, 68 | universal_newlines=True, 69 | shell=False, 70 | env=environ, 71 | stdin=subprocess.PIPE, 72 | stdout=subprocess.PIPE, 73 | stderr=subprocess.PIPE, 74 | bufsize=0, 75 | cwd=cwd, 76 | ) 77 | 78 | if sys.version_info[0] >= 3: 79 | self.out, self.err = self.process.communicate( 80 | input = bytes(self.data, "UTF-8") if self.data else None 81 | ) 82 | else: 83 | self.out, self.err = self.process.communicate(self.data) 84 | except Exception as exc: 85 | self.exc = exc 86 | 87 | 88 | thread = threading.Thread(target=target) 89 | thread.start() 90 | 91 | thread.join(timeout) 92 | if self.exc: 93 | raise self.exc 94 | if _is_alive(thread) : 95 | _terminate_process(self.process) 96 | thread.join(kill_timeout) 97 | if _is_alive(thread): 98 | _kill_process(self.process) 99 | thread.join() 100 | self.returncode = self.process.returncode 101 | return self.out, self.err 102 | 103 | 104 | class ConnectedCommand(object): 105 | def __init__(self, 106 | process=None, 107 | std_in=None, 108 | std_out=None, 109 | std_err=None): 110 | 111 | self._process = process 112 | self.std_in = std_in 113 | self.std_out = std_out 114 | self.std_err = std_out 115 | self._status_code = None 116 | 117 | def __enter__(self): 118 | return self 119 | 120 | def __exit__(self, type, value, traceback): 121 | self.kill() 122 | 123 | @property 124 | def status_code(self): 125 | """The status code of the process. 126 | If the code is None, assume that it's still running. 127 | """ 128 | return self._status_code 129 | 130 | @property 131 | def pid(self): 132 | """The process' PID.""" 133 | return self._process.pid 134 | 135 | def kill(self): 136 | """Kills the process.""" 137 | return self._process.kill() 138 | 139 | def expect(self, bytes, stream=None): 140 | """Block until given bytes appear in the stream.""" 141 | if stream is None: 142 | stream = self.std_out 143 | 144 | def send(self, str, end='\n'): 145 | """Sends a line to std_in.""" 146 | return self._process.stdin.write(str+end) 147 | 148 | def block(self): 149 | """Blocks until command finishes. Returns Response instance.""" 150 | self._status_code = self._process.wait() 151 | 152 | 153 | 154 | class Response(object): 155 | """A command's response""" 156 | 157 | def __init__(self, process=None): 158 | super(Response, self).__init__() 159 | 160 | self._process = process 161 | self.command = None 162 | self.std_err = None 163 | self.std_out = None 164 | self.status_code = None 165 | self.history = [] 166 | 167 | 168 | def __repr__(self): 169 | if len(self.command): 170 | return ''.format(self.command[0]) 171 | else: 172 | return '' 173 | 174 | 175 | def expand_args(command): 176 | """Parses command strings and returns a Popen-ready list.""" 177 | 178 | # Prepare arguments. 179 | if isinstance(command, (str, unicode)): 180 | splitter = shlex.shlex(command.encode('utf-8')) 181 | splitter.whitespace = '|' 182 | splitter.whitespace_split = True 183 | command = [] 184 | 185 | while True: 186 | token = splitter.get_token() 187 | if token: 188 | command.append(token) 189 | else: 190 | break 191 | 192 | command = list(map(shlex.split, command)) 193 | 194 | return command 195 | 196 | 197 | def run(command, data=None, timeout=None, kill_timeout=None, env=None, cwd=None): 198 | """Executes a given commmand and returns Response. 199 | 200 | Blocks until process is complete, or timeout is reached. 201 | """ 202 | 203 | command = expand_args(command) 204 | 205 | history = [] 206 | for c in command: 207 | 208 | if len(history): 209 | # due to broken pipe problems pass only first 10 KiB 210 | data = history[-1].std_out[0:10*1024] 211 | 212 | cmd = Command(c) 213 | try: 214 | out, err = cmd.run(data, timeout, kill_timeout, env, cwd) 215 | status_code = cmd.returncode 216 | except OSError as e: 217 | out, err = '', u"\n".join([e.strerror, traceback.format_exc()]) 218 | status_code = 127 219 | 220 | r = Response(process=cmd) 221 | 222 | r.command = c 223 | r.std_out = out 224 | r.std_err = err 225 | r.status_code = status_code 226 | 227 | history.append(r) 228 | 229 | r = history.pop() 230 | r.history = history 231 | 232 | return r 233 | 234 | 235 | def connect(command, data=None, env=None, cwd=None): 236 | """Spawns a new process from the given command.""" 237 | 238 | # TODO: support piped commands 239 | command_str = expand_args(command).pop() 240 | environ = dict(os.environ) 241 | environ.update(env or {}) 242 | 243 | process = subprocess.Popen(command_str, 244 | universal_newlines=True, 245 | shell=False, 246 | env=environ, 247 | stdin=subprocess.PIPE, 248 | stdout=subprocess.PIPE, 249 | stderr=subprocess.PIPE, 250 | bufsize=0, 251 | cwd=cwd, 252 | ) 253 | 254 | return ConnectedCommand(process=process) 255 | -------------------------------------------------------------------------------- /ext/in_action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/not-kennethreitz/envoy/ab463a14da47bd8334cdf5e64f6b9dd2ba9dd28a/ext/in_action.png -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | import envoy 7 | 8 | try: 9 | from setuptools import setup 10 | except ImportError: 11 | from distutils.core import setup 12 | 13 | 14 | 15 | if sys.argv[-1] == "publish": 16 | os.system("python setup.py sdist bdist_wheel upload") 17 | sys.exit() 18 | 19 | required = [] 20 | 21 | setup( 22 | name='envoy', 23 | version=envoy.__version__, 24 | description='Simple API for running external processes.', 25 | long_description=open('README.rst').read(), 26 | author='Kenneth Reitz', 27 | author_email='me@kennethreitz.com', 28 | url='https://github.com/kennethreitz/envoy', 29 | packages= ['envoy'], 30 | install_requires=required, 31 | license='MIT', 32 | classifiers=( 33 | 'Development Status :: 5 - Production/Stable', 34 | 'Intended Audience :: Developers', 35 | 'Natural Language :: English', 36 | 'License :: OSI Approved :: MIT License', 37 | 'Programming Language :: Python', 38 | 'Programming Language :: Python :: 2.5', 39 | 'Programming Language :: Python :: 2.6', 40 | 'Programming Language :: Python :: 2.7', 41 | 'Programming Language :: Python :: 3.0', 42 | 'Programming Language :: Python :: 3.1', 43 | ), 44 | ) 45 | -------------------------------------------------------------------------------- /test_envoy.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import envoy 3 | import time 4 | 5 | 6 | class SimpleTest(unittest.TestCase): 7 | 8 | def test_input(self): 9 | r = envoy.run("sed s/i/I/g", "Hi") 10 | self.assertEqual(r.std_out.rstrip(), "HI") 11 | self.assertEqual(r.status_code, 0) 12 | 13 | def test_pipe(self): 14 | r = envoy.run("echo -n 'hi'| tr [:lower:] [:upper:]") 15 | self.assertEqual(r.std_out, "HI") 16 | self.assertEqual(r.status_code, 0) 17 | 18 | def test_timeout(self): 19 | r = envoy.run('yes | head', timeout=1) 20 | self.assertEqual(r.std_out, 'y\ny\ny\ny\ny\ny\ny\ny\ny\ny\n') 21 | self.assertEqual(r.status_code, 0) 22 | 23 | # THIS TEST FAILS BECAUSE expand_args DOESN'T HANDLE QUOTES PROPERLY 24 | def test_quoted_args(self): 25 | sentinel = 'quoted_args' * 3 26 | r = envoy.run("python -c 'print \"%s\"'" % sentinel) 27 | self.assertEqual(r.std_out.rstrip(), sentinel) 28 | self.assertEqual(r.status_code, 0) 29 | 30 | def test_non_existing_command(self): 31 | r = envoy.run("blah") 32 | self.assertEqual(r.status_code, 127) 33 | 34 | 35 | class ConnectedCommandTests(unittest.TestCase): 36 | 37 | def test_status_code_none(self): 38 | c = envoy.connect("sleep 5") 39 | self.assertEqual(c.status_code, None) 40 | 41 | def test_status_code_success(self): 42 | c = envoy.connect("sleep 1") 43 | time.sleep(2) 44 | self.assertEqual(c.status_code, 0) 45 | 46 | def test_status_code_failure(self): 47 | c = envoy.connect("sleeep 1") 48 | self.assertEqual(c.status_code, 127) 49 | 50 | def test_input(self): 51 | test_string = 'asdfQWER' 52 | r = envoy.connect("cat | tr [:lower:] [:upper:]") 53 | r.send(test_string) 54 | self.assertEqual(r.std_out, test_string.upper()) 55 | self.assertEqual(r.status_code, 0) 56 | 57 | if __name__ == "__main__": 58 | unittest.main() 59 | --------------------------------------------------------------------------------