From b04e164f424446a384f453c0d8ad5e754e7c0bdf Mon Sep 17 00:00:00 2001 From: Wei Shi Date: Sun, 7 Dec 2014 00:15:54 +0800 Subject: [PATCH 01/17] save --- shcmd/cmd.py | 89 +++++++++++++++++++++++++++++++++++++++++++++-- shcmd/compat.py | 2 ++ tests/test-cmd.py | 12 +++---- 3 files changed, 94 insertions(+), 9 deletions(-) diff --git a/shcmd/cmd.py b/shcmd/cmd.py index a981d9e..980f95b 100644 --- a/shcmd/cmd.py +++ b/shcmd/cmd.py @@ -2,12 +2,17 @@ import contextlib import os +import subprocess import shlex +import threading from . import compat -def split_args(cmd_args): +ITER_CHUNK_SIZE = 1024 + + +def expand_args(cmd_args): """Split command args to args list Returns a list of args @@ -35,9 +40,9 @@ def cd(cd_path): class CmdRequest(object): - def __init__(self, cmd, cwd=None): + def __init__(self, cmd, cwd): self._raw = cmd - self._cmd = split_args(cmd) + self._cmd = expand_args(cmd) self._cwd = os.path.realpath(cwd or os.getcwd()) def __str__(self): @@ -54,3 +59,81 @@ def cmd(self): @property def cwd(self): return self._cwd + + +def run(cmd, cwd=None, timeout=None, stream=False): + request = CmdRequest(cmd, cwd) + + response = CmdExecutor(request, timeout) + + if stream is False: + response.block() + return response + + +class CmdExecutor(object): + def __init__(self, request, timeout): + self._request = request + + self._stdout = None + self._stderr = None + self._return_code = None + self._data_consumed = False + + self._proc = subprocess.Popen( + request.cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=request.cwd + ) + self._timer = threading.Timer(self.kill) + + def block(self): + self._stdout, self._stderr = self._proc.communicate() + self._timer.cancel() + self._return_code = self._proc.returncode + + def iter_content(self, chunk_size=1): + def generator(): + data = self._proc.stdout.read(chunk_size) + while data != compat.empty_bytes: + yield data + data = self._proc.stdout.read(chunk_size) + + self._timer.cancel() + self._data_consumed = True + + if self._data_consumed: + raise ValueError() + + stream_chunk = generator() + return stream_chunk + + def iter_lines(self, chunk_size=ITER_CHUNK_SIZE, delimiter=None): + pending = None + for chunk in self.iter_content(chunk_size=chunk_size): + if pending is not None: + chunk = pending + chunk + if delimiter: + lines = chunk.split(delimiter) + else: + lines = chunk.splitlines() + + if lines and lines[-1] and lines[-1][-1] == chunk[-1]: + pending = lines.pop() + else: + pending = None + for line in lines: + yield line + if pending is not None: + yield pending + + def kill(self): + self._proc.kill() + + def raise_for_return_code(self): + if self.ok is False: + pass + + def ok(self): + return self._return_code == 0 diff --git a/shcmd/compat.py b/shcmd/compat.py index fcfb9b8..e81eb72 100644 --- a/shcmd/compat.py +++ b/shcmd/compat.py @@ -87,6 +87,7 @@ str = unicode basestring = basestring numeric_types = (int, long, float) + empty_bytes = "" elif is_py3: from io import StringIO @@ -96,3 +97,4 @@ bytes = bytes basestring = (str, bytes) numeric_types = (int, float) + empty_bytes = b"" diff --git a/tests/test-cmd.py b/tests/test-cmd.py index bbea53c..df3bfef 100644 --- a/tests/test-cmd.py +++ b/tests/test-cmd.py @@ -8,27 +8,27 @@ import shcmd.cmd -def test_asc_split_args(): +def test_asc_expand_args(): correct = ["/bin/bash", "echo", "上海崇明岛"] # str - result = shcmd.cmd.split_args("/bin/bash echo 上海崇明岛") + result = shcmd.cmd.expand_args("/bin/bash echo 上海崇明岛") nose.tools.eq_(result, correct, "str test failed") # unicode - result = shcmd.cmd.split_args(u"/bin/bash echo 上海崇明岛") + result = shcmd.cmd.expand_args(u"/bin/bash echo 上海崇明岛") nose.tools.eq_(result, correct, "unicode test failed") # bytes - result = shcmd.cmd.split_args(u"/bin/bash echo 上海崇明岛".encode("utf8")) + result = shcmd.cmd.expand_args(u"/bin/bash echo 上海崇明岛".encode("utf8")) nose.tools.eq_(result, correct, "bytes test failed") # list - result = shcmd.cmd.split_args(["/bin/bash", "echo", "上海崇明岛"]) + result = shcmd.cmd.expand_args(["/bin/bash", "echo", "上海崇明岛"]) nose.tools.eq_(result, correct, "list test failed") # tuple - result = shcmd.cmd.split_args(("/bin/bash", "echo", "上海崇明岛")) + result = shcmd.cmd.expand_args(("/bin/bash", "echo", "上海崇明岛")) nose.tools.eq_(result, correct, "tuple test failed") From d84a36ebd97dbdfa6f8a2e3948503c9f8e948cc7 Mon Sep 17 00:00:00 2001 From: Wei Shi Date: Sun, 7 Dec 2014 22:57:44 +0800 Subject: [PATCH 02/17] save --- shcmd/proc.py | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 shcmd/proc.py diff --git a/shcmd/proc.py b/shcmd/proc.py new file mode 100644 index 0000000..59fb39b --- /dev/null +++ b/shcmd/proc.py @@ -0,0 +1,94 @@ +import contextlib +import logging +import subprocess +import threading + + +logger = logging.getLogger(__name__) + + +class CmdProc(object): + def __init__(self, request, timeout, decode_unicode): + self._request = request + self._proc = None + self._timer = threading.Timer(timeout, self.kill) + + self._codec = decode_unicode + + if decode_unicode is None: + # raw bytes + self._stdout = self._stderr = b"" + else: + # decode to string + self._stdout = self._stderr = "" + + @property + def stdout(self): + return self._stdout + + @property + def stderr(self): + return self._stderr + + @property + def proc(self): + return self._proc + + @property + def request(self): + return self._request + + def kill(self): + if self.proc is None: + logger.warn("{0} not started".format(self)) + elif self.proc.returncode is not None: + logger.warn("{0} ended".format(self)) + else: + self.proc.kill() + logger.info("{0} killed".format(self)) + + def decode_unicode(self, raw_bytes): + if self._codec is None: + return raw_bytes + else: + return raw_bytes.decode(self._codec) + + def block(self): + """ + :param decode_unicode: (default is False, not decode), + set decode_unicode to "utf8" so stdout/stderr willl be decoded + """ + with self.run_with_timeout() as proc: + stdout, stderr = proc.communicate() + + self._stdout = self.decode_unicode(stdout) + self._stderr = self.decode_unicode(stderr) + return self.stdout, self.stderr + + def stream(self, chunk_size=1): + """ + :param decode_unicode: (default is False, not decode), + set decode_unicode to "utf8" so stdout/stderr willl be decoded + """ + with self.run_with_timeout() as proc: + while proc.poll() is None: + data = self.decode_unicode(proc.stdout.read(chunk_size)) + self._stdout += data + yield self.decode_unicode(data) + self._stderr = self.decode_unicode(proc.stderr) + + @contextlib.contextmanager + def run_with_timeout(self): + timer = threading.Timer(self.kill, self.timeout) + try: + proc = subprocess.Popen( + self.request.cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=self.request.cwd, + env=self.request.env + ) + timer.start() + yield proc + finally: + timer.cancel() From 14b8ca45bf375bb4f10c65eac1d28aeac3803982 Mon Sep 17 00:00:00 2001 From: Wei Shi Date: Mon, 22 Dec 2014 11:17:02 +0800 Subject: [PATCH 03/17] save --- shcmd/cmd.py | 125 ---------------------------------------------- shcmd/proc.py | 60 +++++++++++++++++++--- shcmd/request.py | 46 +++++++++++++++++ shcmd/response.py | 55 ++++++++++++++++++++ 4 files changed, 153 insertions(+), 133 deletions(-) create mode 100644 shcmd/request.py create mode 100644 shcmd/response.py diff --git a/shcmd/cmd.py b/shcmd/cmd.py index 980f95b..757c65d 100644 --- a/shcmd/cmd.py +++ b/shcmd/cmd.py @@ -2,31 +2,6 @@ import contextlib import os -import subprocess -import shlex -import threading - -from . import compat - - -ITER_CHUNK_SIZE = 1024 - - -def expand_args(cmd_args): - """Split command args to args list - Returns a list of args - - :param cmd_args: command args, can be tuple, list or str - """ - if isinstance(cmd_args, (tuple, list)): - args_list = list(cmd_args) - elif compat.is_py2 and isinstance(cmd_args, compat.str): - args_list = shlex.split(cmd_args.encode("utf8")) - elif compat.is_py3 and isinstance(cmd_args, compat.bytes): - args_list = shlex.split(cmd_args.decode("utf8")) - else: - args_list = shlex.split(cmd_args) - return args_list @contextlib.contextmanager @@ -37,103 +12,3 @@ def cd(cd_path): yield finally: os.chdir(oricwd) - - -class CmdRequest(object): - def __init__(self, cmd, cwd): - self._raw = cmd - self._cmd = expand_args(cmd) - self._cwd = os.path.realpath(cwd or os.getcwd()) - - def __str__(self): - return "".format(self._raw, self.cwd) - - @property - def raw(self): - return self._raw - - @property - def cmd(self): - return self._cmd[:] - - @property - def cwd(self): - return self._cwd - - -def run(cmd, cwd=None, timeout=None, stream=False): - request = CmdRequest(cmd, cwd) - - response = CmdExecutor(request, timeout) - - if stream is False: - response.block() - return response - - -class CmdExecutor(object): - def __init__(self, request, timeout): - self._request = request - - self._stdout = None - self._stderr = None - self._return_code = None - self._data_consumed = False - - self._proc = subprocess.Popen( - request.cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - cwd=request.cwd - ) - self._timer = threading.Timer(self.kill) - - def block(self): - self._stdout, self._stderr = self._proc.communicate() - self._timer.cancel() - self._return_code = self._proc.returncode - - def iter_content(self, chunk_size=1): - def generator(): - data = self._proc.stdout.read(chunk_size) - while data != compat.empty_bytes: - yield data - data = self._proc.stdout.read(chunk_size) - - self._timer.cancel() - self._data_consumed = True - - if self._data_consumed: - raise ValueError() - - stream_chunk = generator() - return stream_chunk - - def iter_lines(self, chunk_size=ITER_CHUNK_SIZE, delimiter=None): - pending = None - for chunk in self.iter_content(chunk_size=chunk_size): - if pending is not None: - chunk = pending + chunk - if delimiter: - lines = chunk.split(delimiter) - else: - lines = chunk.splitlines() - - if lines and lines[-1] and lines[-1][-1] == chunk[-1]: - pending = lines.pop() - else: - pending = None - for line in lines: - yield line - if pending is not None: - yield pending - - def kill(self): - self._proc.kill() - - def raise_for_return_code(self): - if self.ok is False: - pass - - def ok(self): - return self._return_code == 0 diff --git a/shcmd/proc.py b/shcmd/proc.py index 59fb39b..acad52b 100644 --- a/shcmd/proc.py +++ b/shcmd/proc.py @@ -1,3 +1,5 @@ +# -*- coding: utf8 -*- + import contextlib import logging import subprocess @@ -8,12 +10,18 @@ class CmdProc(object): + """ + Simple Wrapper around the built-in subprocess module + + use threading.Timer to add timeout option + easy interface for get streamed output of stdout + """ def __init__(self, request, timeout, decode_unicode): self._request = request - self._proc = None self._timer = threading.Timer(timeout, self.kill) self._codec = decode_unicode + self._return_code = None if decode_unicode is None: # raw bytes @@ -24,21 +32,23 @@ def __init__(self, request, timeout, decode_unicode): @property def stdout(self): + """Proc's stdout""" return self._stdout @property def stderr(self): + """Proc's stderr""" return self._stderr - @property - def proc(self): - return self._proc - @property def request(self): + """The original request""" return self._request def kill(self): + """Kill proc if started + Returns True if proc is killed actually + """ if self.proc is None: logger.warn("{0} not started".format(self)) elif self.proc.returncode is not None: @@ -48,15 +58,26 @@ def kill(self): logger.info("{0} killed".format(self)) def decode_unicode(self, raw_bytes): + """Decode bytes into unicode + Returns unicode + """ if self._codec is None: return raw_bytes else: return raw_bytes.decode(self._codec) def block(self): - """ + """Blocked executation + Return (stdout, stderr) tuple + :param decode_unicode: (default is False, not decode), set decode_unicode to "utf8" so stdout/stderr willl be decoded + + Usage:: + + >>> stdout, stderr = proc.block(1024) + >>> stdout == proc.stdout, "stdout output error" + """ with self.run_with_timeout() as proc: stdout, stderr = proc.communicate() @@ -66,19 +87,41 @@ def block(self): return self.stdout, self.stderr def stream(self, chunk_size=1): - """ + """Streamed yield stdout + Return a generator + :param decode_unicode: (default is False, not decode), set decode_unicode to "utf8" so stdout/stderr willl be decoded + + Usage:: + + >>> all_data = "" + >>> for data in proc.stream(1024): + ... all_data += data + ... + >>> assert all_data == proc.stdout, "stdout output error" + """ with self.run_with_timeout() as proc: while proc.poll() is None: data = self.decode_unicode(proc.stdout.read(chunk_size)) self._stdout += data yield self.decode_unicode(data) - self._stderr = self.decode_unicode(proc.stderr) + + self._stderr = self.decode_unicode(proc.stderr) @contextlib.contextmanager def run_with_timeout(self): + """Execute this proc with timeout + + Usage:: + + >>> with cmd_proc.run_with_timeout() as cmd_proc: + ... stdout, stderr = cmd_proc.communicate() + ... + >>> assert cmd_proc.proc.return_code == 0, "proc exec failed" + + """ timer = threading.Timer(self.kill, self.timeout) try: proc = subprocess.Popen( @@ -90,5 +133,6 @@ def run_with_timeout(self): ) timer.start() yield proc + self._return_code = proc.returncode finally: timer.cancel() diff --git a/shcmd/request.py b/shcmd/request.py new file mode 100644 index 0000000..39cca2e --- /dev/null +++ b/shcmd/request.py @@ -0,0 +1,46 @@ +# -*- coding: utf8 -*- + + +import os +import shlex + +from . import compat + + +def expand_args(cmd_args): + """Split command args to args list + Returns a list of args + + :param cmd_args: command args, can be tuple, list or str + """ + if isinstance(cmd_args, (tuple, list)): + args_list = list(cmd_args) + elif compat.is_py2 and isinstance(cmd_args, compat.str): + args_list = shlex.split(cmd_args.encode("utf8")) + elif compat.is_py3 and isinstance(cmd_args, compat.bytes): + args_list = shlex.split(cmd_args.decode("utf8")) + else: + args_list = shlex.split(cmd_args) + return args_list + + +class CmdRequest(object): + def __init__(self, cmd, cwd): + self._raw = cmd + self._cmd = expand_args(cmd) + self._cwd = os.path.realpath(cwd or os.getcwd()) + + def __str__(self): + return "".format(self._raw, self.cwd) + + @property + def raw(self): + return self._raw + + @property + def cmd(self): + return self._cmd[:] + + @property + def cwd(self): + return self._cwd diff --git a/shcmd/response.py b/shcmd/response.py new file mode 100644 index 0000000..5ef2257 --- /dev/null +++ b/shcmd/response.py @@ -0,0 +1,55 @@ +# -*- coding: utf8 -*- + + +ITER_CHUNK_SIZE = 1024 + + +class CmdResponse(object): + def __init__(self, proc): + self._proc = proc + + @property + def request(self): + return self._proc.request + + @property + def return_code(self): + return self._proc.return_code + + @property + def stdout(self): + return self._proc.stdout + + @property + def stderr(self): + return self._proc.stderr + + def iter_content(self, chunk_size=1): + for data in self._porc.stream(chunk_size): + yield data + + def iter_lines(self, chunk_size=ITER_CHUNK_SIZE, delimiter=None): + pending = None + for chunk in self.iter_content(chunk_size=chunk_size): + if pending is not None: + chunk = pending + chunk + if delimiter: + lines = chunk.split(delimiter) + else: + lines = chunk.splitlines() + + if lines and lines[-1] and lines[-1][-1] == chunk[-1]: + pending = lines.pop() + else: + pending = None + for line in lines: + yield line + if pending is not None: + yield pending + + def raise_for_return_code(self): + if self.ok is False: + pass + + def ok(self): + return self.return_code == 0 From 4d8aab921f26fb7b862d249e8b24ba1dda29c7d2 Mon Sep 17 00:00:00 2001 From: SkyLothar Date: Thu, 9 Apr 2015 22:36:25 +0800 Subject: [PATCH 04/17] new proc --- README.md | 38 --------- setup.py | 28 +++---- shcmd/__init__.py | 25 ++++++ shcmd/cmd.py | 11 +++ shcmd/compat.py | 100 ---------------------- shcmd/errors.py | 2 +- shcmd/proc.py | 209 ++++++++++++++++++++++++++-------------------- shcmd/request.py | 83 ++++++++++++------ shcmd/result.py | 42 ---------- tests/test-cmd.py | 57 ------------- tox.ini | 2 +- 11 files changed, 228 insertions(+), 369 deletions(-) delete mode 100644 README.md delete mode 100644 shcmd/compat.py delete mode 100644 shcmd/result.py delete mode 100644 tests/test-cmd.py diff --git a/README.md b/README.md deleted file mode 100644 index af6bab7..0000000 --- a/README.md +++ /dev/null @@ -1,38 +0,0 @@ -SHCMD ~~上海崇明岛~~ -==================== - -Note: Work in Progress. - -This lib aims to provide a Human friendly interface for subprocess. - -If you need piped subprocesses, give [envoy](https://github.com/kennethreitz/envoy) a try. - - -[![Build Status][travis-image]][travis-url] -[![Coverage Status][coverage-image]][coverage-url] -[![Requirements Status][req-status-image]][req-status-url] - - -### Usage -```python -import shcmd - -with shcmd.cd("/tmp"): - # get result directly - assert shcmd.run("pwd") == "/tmp" - # get streamed result packed in a generator - streamed = shcmd.run("ls", stream=True) - for filename in streamed.iter_lines(): - print(filename) - # get full stdout/stderr - print(streamed.stdout) - print(streamed.stderr) -``` - - -[travis-url]: https://travis-ci.org/SkyLothar/shcmd -[travis-image]: https://travis-ci.org/SkyLothar/shcmd.svg?branch=master -[coverage-image]: https://coveralls.io/repos/SkyLothar/shcmd/badge.png -[coverage-url]: https://coveralls.io/r/SkyLothar/shcmd -[req-status-url]: https://requires.io/github/SkyLothar/shcmd/requirements/?branch=master -[req-status-image]: https://requires.io/github/SkyLothar/shcmd/requirements.svg?branch=master diff --git a/setup.py b/setup.py index 6e79086..d18ea9f 100644 --- a/setup.py +++ b/setup.py @@ -1,16 +1,16 @@ # -*- coding: utf-8 -*- -__version__ = "" -__author__ = "" -__email__ = "" -__url__ = "" - - import os import sys from codecs import open +__version__ = "0.1.0" +__author__ = "SkyLothar" +__email__ = "allothar@gmail.com" +__url__ = "https://github.com/skylothar/shcmd" + + try: import setuptools except ImportError: @@ -25,7 +25,7 @@ packages = ["shcmd"] -with open("README.md", "r", "utf-8") as f: +with open("README.rst", "r", "utf-8") as f: readme = f.read() with open("tests/requirements.txt", "r", "utf-8") as f: @@ -35,33 +35,31 @@ setuptools.setup( name="shcmd", version=__version__, - description="", + description="simple command-line wrapper", long_description=readme, author=__author__, author_email=__email__, url=__url__, packages=packages, package_data={ - "": ["LICENSE", "NOTICE"] + "": ["LICENSE"] }, package_dir={ "shcmd": "shcmd" }, include_package_data=True, - install_requires=[], license="Apache 2.0", zip_safe=False, - classifiers=( - "Development Status :: 5 - Production/Stable", + classifiers=[ + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Natural Language :: English", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", - "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4" - - ), + ], setup_requires=["nose >= 1.0"], tests_require=tests_require, test_suite="nose.collector" diff --git a/shcmd/__init__.py b/shcmd/__init__.py index e69de29..3bf963a 100644 --- a/shcmd/__init__.py +++ b/shcmd/__init__.py @@ -0,0 +1,25 @@ +__version__ = "0.1.2" +__author__ = "SkyLothar" +__email__ = "allothar@gmail.com" +__url__ = "https://github.com/skylothar/shcmd" + +import os + +from .proc import Proc +from .utils import expand_args + + +DEFAULT_TIMEOUT = 60 + + +def run(cmd, cwd=None, env=None, timeout=None, stream=False): + proc = Proc( + expand_args(cmd), + os.path.realpath(cwd or os.getcwd()), + env=env or {}, + timeout=timeout or DEFAULT_TIMEOUT + ) + + if not stream: + proc.block() + return proc diff --git a/shcmd/cmd.py b/shcmd/cmd.py index 757c65d..544c8a0 100644 --- a/shcmd/cmd.py +++ b/shcmd/cmd.py @@ -1,6 +1,7 @@ # -*- coding: utf8 -*- import contextlib +import functools import os @@ -12,3 +13,13 @@ def cd(cd_path): yield finally: os.chdir(oricwd) + + +def cd_to(path): + def cd_to_decorator(func): + @functools.wraps(func) + def _cd_and_exec(*args, **kwargs): + with cd(path): + return func(*args, **kwargs) + return _cd_and_exec + return cd_to_decorator diff --git a/shcmd/compat.py b/shcmd/compat.py deleted file mode 100644 index e81eb72..0000000 --- a/shcmd/compat.py +++ /dev/null @@ -1,100 +0,0 @@ -# -*- coding: utf-8 -*- - -""" -pythoncompat -""" - -import sys - -# ------- -# Pythons -# ------- - -# Syntax sugar. -_ver = sys.version_info - -#: Python 2.x? -is_py2 = (_ver[0] == 2) - -#: Python 3.x? -is_py3 = (_ver[0] == 3) - -#: Python 3.0.x -is_py30 = (is_py3 and _ver[1] == 0) - -#: Python 3.1.x -is_py31 = (is_py3 and _ver[1] == 1) - -#: Python 3.2.x -is_py32 = (is_py3 and _ver[1] == 2) - -#: Python 3.3.x -is_py33 = (is_py3 and _ver[1] == 3) - -#: Python 3.4.x -is_py34 = (is_py3 and _ver[1] == 4) - -#: Python 2.7.x -is_py27 = (is_py2 and _ver[1] == 7) - -#: Python 2.6.x -is_py26 = (is_py2 and _ver[1] == 6) - -#: Python 2.5.x -is_py25 = (is_py2 and _ver[1] == 5) - - -# --------- -# Platforms -# --------- - - -# Syntax sugar. -_ver = sys.version.lower() - -is_pypy = ('pypy' in _ver) -is_jython = ('jython' in _ver) -is_ironpython = ('iron' in _ver) - -# Assume CPython, if nothing else. -is_cpython = not any((is_pypy, is_jython, is_ironpython)) - -# Windows-based system. -is_windows = 'win32' in str(sys.platform).lower() - -# Standard Linux 2+ system. -is_linux = ('linux' in str(sys.platform).lower()) -is_osx = ('darwin' in str(sys.platform).lower()) -is_hpux = ('hpux' in str(sys.platform).lower()) # Complete guess. -is_solaris = ('solar==' in str(sys.platform).lower()) # Complete guess. - -try: - import simplejson as json -except (ImportError, SyntaxError): - # simplejson does not support Python 3.2, it thows a SyntaxError - # because of u'...' Unicode literals. - import json - -# --------- -# Specifics -# --------- - -if is_py2: - from StringIO import StringIO - - builtin_str = str - bytes = str - str = unicode - basestring = basestring - numeric_types = (int, long, float) - empty_bytes = "" - -elif is_py3: - from io import StringIO - - builtin_str = str - str = str - bytes = bytes - basestring = (str, bytes) - numeric_types = (int, float) - empty_bytes = b"" diff --git a/shcmd/errors.py b/shcmd/errors.py index b34eebb..31e3d45 100644 --- a/shcmd/errors.py +++ b/shcmd/errors.py @@ -1,2 +1,2 @@ -class CmdError(Exception): +class ShCmdError(Exception): pass diff --git a/shcmd/proc.py b/shcmd/proc.py index acad52b..098b4bd 100644 --- a/shcmd/proc.py +++ b/shcmd/proc.py @@ -1,118 +1,95 @@ # -*- coding: utf8 -*- import contextlib +import io import logging import subprocess import threading +import time + +from .errors import ShCmdError logger = logging.getLogger(__name__) +LINE_CHUNK_SIZE = 1024 + + +def kill_proc(proc, cmd, started_at): + """kill proc if started + returns True if proc is killed actually + """ + if proc.returncode is None: + proc.kill() + -class CmdProc(object): +class Proc(object): """ Simple Wrapper around the built-in subprocess module use threading.Timer to add timeout option easy interface for get streamed output of stdout """ - def __init__(self, request, timeout, decode_unicode): - self._request = request - self._timer = threading.Timer(timeout, self.kill) - - self._codec = decode_unicode - self._return_code = None + codec = "utf8" - if decode_unicode is None: - # raw bytes - self._stdout = self._stderr = b"" - else: - # decode to string - self._stdout = self._stderr = "" + def __init__(self, cmd, cwd, env, timeout): + self._cmd = cmd + self._cwd = cwd + self._env = env + self._timeout = timeout + self._return_code = self._stdout = self._stderr = None @property - def stdout(self): - """Proc's stdout""" - return self._stdout + def cmd(self): + return self._cmd[:] @property - def stderr(self): - """Proc's stderr""" - return self._stderr + def cwd(self): + return self._cwd @property - def request(self): - """The original request""" - return self._request - - def kill(self): - """Kill proc if started - Returns True if proc is killed actually - """ - if self.proc is None: - logger.warn("{0} not started".format(self)) - elif self.proc.returncode is not None: - logger.warn("{0} ended".format(self)) - else: - self.proc.kill() - logger.info("{0} killed".format(self)) - - def decode_unicode(self, raw_bytes): - """Decode bytes into unicode - Returns unicode - """ - if self._codec is None: - return raw_bytes - else: - return raw_bytes.decode(self._codec) - - def block(self): - """Blocked executation - Return (stdout, stderr) tuple - - :param decode_unicode: (default is False, not decode), - set decode_unicode to "utf8" so stdout/stderr willl be decoded - - Usage:: - - >>> stdout, stderr = proc.block(1024) - >>> stdout == proc.stdout, "stdout output error" - - """ - with self.run_with_timeout() as proc: - stdout, stderr = proc.communicate() + def env(self): + return self._env.copy() - self._stdout = self.decode_unicode(stdout) - self._stderr = self.decode_unicode(stderr) - return self.stdout, self.stderr + @property + def timeout(self): + return self._timeout - def stream(self, chunk_size=1): - """Streamed yield stdout - Return a generator + @property + def stdout(self): + """proc's stdout.""" + return self._stdout.decode(self.codec) - :param decode_unicode: (default is False, not decode), - set decode_unicode to "utf8" so stdout/stderr willl be decoded + @property + def stderr(self): + """proc's stderr.""" + return self._stderr.decode(self.codec) - Usage:: + @property + def return_code(self): + return self._return_code - >>> all_data = "" - >>> for data in proc.stream(1024): - ... all_data += data - ... - >>> assert all_data == proc.stdout, "stdout output error" + @property + def content(self): + return self._stdout - """ - with self.run_with_timeout() as proc: - while proc.poll() is None: - data = self.decode_unicode(proc.stdout.read(chunk_size)) - self._stdout += data - yield self.decode_unicode(data) + @property + def ok(self): + return self.return_code == 0 - self._stderr = self.decode_unicode(proc.stderr) + def raise_for_error(self): + if not self.ok: + tip = "running {0} @<{1}> error, return code {2}".format( + " ".join(self.cmd), self.cwd, self.return_code + ) + logger.error("{0}\nstdout:{1}\nstderr:{2}\n".format( + tip, self.stdout, self.stderr + )) + raise ShCmdError(tip) @contextlib.contextmanager - def run_with_timeout(self): - """Execute this proc with timeout + def _stream(self): + """Execute subprocess with timeout Usage:: @@ -122,17 +99,71 @@ def run_with_timeout(self): >>> assert cmd_proc.proc.return_code == 0, "proc exec failed" """ - timer = threading.Timer(self.kill, self.timeout) + timer = None try: proc = subprocess.Popen( - self.request.cmd, + self.cmd, cwd=self.cwd, env=self.env, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - cwd=self.request.cwd, - env=self.request.env + stderr=subprocess.PIPE + ) + timer = threading.Timer( + self.timeout, + kill_proc, [proc, self.cmd, time.time()] ) timer.start() yield proc - self._return_code = proc.returncode finally: - timer.cancel() + if timer: + timer.cancel() + + def iter_lines(self): + remain = "" + for data in self.iter_content(LINE_CHUNK_SIZE): + line_break_found = data[:1] in ("\n", "\r") + lines = data.decode(self.codec).splitlines() + lines[0] = remain + lines[0] + if not line_break_found: + remain = lines.pop() + for line in lines: + yield line + + def iter_content(self, chunk_size=1): + if self.return_code is not None: + stdout = io.BytesIO(self._stdout) + data = stdout.read(chunk_size) + while data: + yield data + data = stdout.read(chunk_size) + else: + data = b'' + started_at = time.time() + with self._stream() as proc: + while proc.poll() is None: + chunk = proc.stdout.read(chunk_size) + yield chunk + data += chunk + + if proc.returncode == -9: + raise subprocess.TimeoutExpired( + proc.args, time.time() - started_at + ) + + chunk = proc.stdout.read(chunk_size) + while chunk: + yield chunk + chunk = proc.stdout.read(chunk_size) + + self._return_code = proc.returncode + self._stderr = proc.stderr.read() + self._stdout = data + + def block(self): + """blocked executation.""" + if self._return_code is None: + proc = subprocess.Popen( + self.cmd, cwd=self.cwd, env=self.env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + self._stdout, self._stderr = proc.communicate(timeout=self.timeout) + self._return_code = proc.returncode diff --git a/shcmd/request.py b/shcmd/request.py index 39cca2e..a3ab721 100644 --- a/shcmd/request.py +++ b/shcmd/request.py @@ -3,44 +3,75 @@ import os import shlex +import subprocess +import threading -from . import compat -def expand_args(cmd_args): - """Split command args to args list - Returns a list of args - - :param cmd_args: command args, can be tuple, list or str - """ - if isinstance(cmd_args, (tuple, list)): - args_list = list(cmd_args) - elif compat.is_py2 and isinstance(cmd_args, compat.str): - args_list = shlex.split(cmd_args.encode("utf8")) - elif compat.is_py3 and isinstance(cmd_args, compat.bytes): - args_list = shlex.split(cmd_args.decode("utf8")) - else: - args_list = shlex.split(cmd_args) - return args_list - class CmdRequest(object): - def __init__(self, cmd, cwd): - self._raw = cmd + def __init__(self, cmd, cwd=None, timeout=None): self._cmd = expand_args(cmd) self._cwd = os.path.realpath(cwd or os.getcwd()) + self._timeout = None - def __str__(self): - return "".format(self._raw, self.cwd) + self._timer = threading.Timer(self.kill, self._timeout) - @property - def raw(self): - return self._raw + def __str__(self): + return "".format(" ".join(self._cmd)) @property - def cmd(self): - return self._cmd[:] + def command(self): + return "cd {0} && {1}".format(self.cwd, " ".join(self._cmd)) @property def cwd(self): return self._cwd + + @property + def timeout(self): + return self._timeout + + def kill(self): + if self._proc is None: + logger.warn("{0} nerver started".format(self)) + elif self._proc.returncode is not None: + logger.warn("{0} ended".format(self)) + else: + self._proc.kill() + + def run(self, block=True): + self._proc = None subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=cwd + ) + if block: + stdout, stderr = self._proc.communicate(timeout=self.timeout) + return CmdResponse(stdout, stderr, self) + else: + pass + + +class CmdResponse(object): + def __init__(self, request): + self._stdout = stdout + self._stderr = stderr + self._request = request + + @property + def stdout(self): + return self._stdout.decode("utf8") + + @property + def stderr(self): + return self._stderr.decode("utf8") + + @property + def content(self): + return self._stdout + + @property + def request(self): + return self._request diff --git a/shcmd/result.py b/shcmd/result.py deleted file mode 100644 index e4583f0..0000000 --- a/shcmd/result.py +++ /dev/null @@ -1,42 +0,0 @@ -from . import compat - - -ITER_CHUNK_SIZE = 512 - - -class Result(compat.str): - """ - Simple string subclass to allow arbitrary attribute access. - """ - @property - def stdout(self): - return str(self) - - @property - def stderr(self): - return self._stderr - - def ok(self): - return self.retcode == 0 - - def raise_for_retcode(self): - if not self.ok: - raise ValueError() - - def retcode(self): - return self._retcode - - def iter_lines( - self, - chunk_size=ITER_CHUNK_SIZE, - decode_unicode=None, - delimiter=None - ): - pass - - def iter_content(self, chunk_size=1, decode_unicode=False): - pass - - @property - def reason(self): - pass diff --git a/tests/test-cmd.py b/tests/test-cmd.py deleted file mode 100644 index df3bfef..0000000 --- a/tests/test-cmd.py +++ /dev/null @@ -1,57 +0,0 @@ -# -*- coding: utf8 -*- - -import os -import tempfile - -import nose - -import shcmd.cmd - - -def test_asc_expand_args(): - correct = ["/bin/bash", "echo", "上海崇明岛"] - - # str - result = shcmd.cmd.expand_args("/bin/bash echo 上海崇明岛") - nose.tools.eq_(result, correct, "str test failed") - - # unicode - result = shcmd.cmd.expand_args(u"/bin/bash echo 上海崇明岛") - nose.tools.eq_(result, correct, "unicode test failed") - - # bytes - result = shcmd.cmd.expand_args(u"/bin/bash echo 上海崇明岛".encode("utf8")) - nose.tools.eq_(result, correct, "bytes test failed") - - # list - result = shcmd.cmd.expand_args(["/bin/bash", "echo", "上海崇明岛"]) - nose.tools.eq_(result, correct, "list test failed") - - # tuple - result = shcmd.cmd.expand_args(("/bin/bash", "echo", "上海崇明岛")) - nose.tools.eq_(result, correct, "tuple test failed") - - -def test_cd(): - tmpdir = os.path.realpath(tempfile.gettempdir()) - oridir = os.getcwd() - nose.tools.ok_(oridir != tmpdir, "tmp dir == curr dir") - with shcmd.cmd.cd(tmpdir): - nose.tools.eq_( - os.path.realpath(os.getcwd()), - tmpdir, - "not cd to dir" - ) - nose.tools.eq_(os.getcwd(), oridir, "not cd back") - - -def test_request(): - cwd = os.path.realpath("tmp") - cmd_req = shcmd.cmd.CmdRequest("/bin/bash eval 'ls'", "tmp") - nose.tools.eq_(cmd_req.cmd, ["/bin/bash", "eval", "ls"]) - nose.tools.eq_(cmd_req.raw, "/bin/bash eval 'ls'") - nose.tools.eq_(cmd_req.cwd, cwd) - nose.tools.eq_( - str(cmd_req), - "".format(cwd) - ) diff --git a/tox.ini b/tox.ini index b1adfea..e313e40 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py34, flake8 +envlist = py34, flake8 skipsdist = True [testenv] From 95cd47ddc115d640cc2352628af08ab513bf2802 Mon Sep 17 00:00:00 2001 From: SkyLothar Date: Thu, 9 Apr 2015 22:38:13 +0800 Subject: [PATCH 05/17] add tar --- README.rst | 34 +++++++++++++ shcmd/tar.py | 40 +++++++++++++++ shcmd/utils.py | 16 ++++++ tests/test-run.py | 115 ++++++++++++++++++++++++++++++++++++++++++++ tests/test-utils.py | 50 +++++++++++++++++++ 5 files changed, 255 insertions(+) create mode 100644 README.rst create mode 100644 shcmd/tar.py create mode 100644 shcmd/utils.py create mode 100644 tests/test-run.py create mode 100644 tests/test-utils.py diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..338e30e --- /dev/null +++ b/README.rst @@ -0,0 +1,34 @@ +SHCMD +----- + +Note: Work in Progress. + +This lib aims to provide a Human friendly interface for subprocess. + +If you need piped subprocesses, give envoy_ a try. + +.. image:: https://travis-ci.org/SkyLothar/shcmd.svg?branch=master + :target: https://travis-ci.org/SkyLothar/shcmd +.. image:: https://travis-ci.org/SkyLothar/shcmd.svg?branch=master + :target: https://travis-ci.org/SkyLothar/shcmd +.. image:: https://img.shields.io/coveralls/SkyLothar/shcmd/master.svg + :target: https://coveralls.io/r/SkyLothar/shcmd + +Usage +^^^^^^ +.. codeblock::python + + import shcmd + + with shcmd.cd("/tmp"): + # get result directly + assert shcmd.run("pwd") == "/tmp" + # get streamed result packed in a generator + streamed = shcmd.run("ls", stream=True) + for filename in streamed.iter_lines(): + print(filename) + # get full stdout/stderr + print(streamed.stdout) + print(streamed.stderr) + +.. _`envoy`: https://github.com/kennethreitz/envoy diff --git a/shcmd/tar.py b/shcmd/tar.py new file mode 100644 index 0000000..24013ae --- /dev/null +++ b/shcmd/tar.py @@ -0,0 +1,40 @@ +import io +import os +import tarfile + + +def tar_generator(file_list): + """ + :Usage: + >>> tg = tar_generator([ + ... "/path/to/a", + ... "/path/to/b", + ... ("filename", "content") + ... ]) + >>> len(b"".join(tg)) + 1024 + + :param file_list: a list of file_path or (file_name, file_content) tuple + """ + tar_buffer = io.BytesIO() + tar_obj = tarfile.TarFile(mode="w", fileobj=tar_buffer, dereference=True) + + for file_detail in file_list: + last = tar_buffer.tell() + if isinstance(file_detail, str): + tar_obj.add(file_detail) + else: + content_name, content = file_detail + content_info = tarfile.TarInfo(content_name) + if isinstance(content, io.BytesIO): + content_info.size = len(content.getvalue()) + else: + content_info.size = len(content) + content = io.BytesIO(content) + + tar_buffer.seek(last, os.SEEK_SET) + data = tar_buffer.read() + yield data + + tar_obj.close() + yield tar_buffer.read() diff --git a/shcmd/utils.py b/shcmd/utils.py new file mode 100644 index 0000000..428bb2a --- /dev/null +++ b/shcmd/utils.py @@ -0,0 +1,16 @@ +# -*- coding: utf8 -*- + +import shlex + + +def expand_args(cmd_args): + """split command args to args list + returns a list of args + + :param cmd_args: command args, can be tuple, list or str + """ + if isinstance(cmd_args, (tuple, list)): + args_list = list(cmd_args) + else: + args_list = shlex.split(cmd_args) + return args_list diff --git a/tests/test-run.py b/tests/test-run.py new file mode 100644 index 0000000..3d6a356 --- /dev/null +++ b/tests/test-run.py @@ -0,0 +1,115 @@ +# -*- coding: utf8 -*- + +import os +import tempfile +import subprocess +import uuid + +import mock + +import shcmd +from shcmd.errors import ShCmdError + + +from nose import tools + + +class TestRun(object): + def setup(self): + self.tmp = os.path.realpath(tempfile.gettempdir()) + self.ls_cmd = "ls {0}".format(self.tmp) + self.ramdom_files = [] + for __ in range(4): + fname = os.path.join(self.tmp, uuid.uuid4().hex) + with open(fname, "wt") as f: + f.write("test-run") + self.ramdom_files.append(fname) + + def teardown(self): + for fname in self.ramdom_files: + os.remove(fname) + + def test_block(self): + # run once + proc = shcmd.run(self.ls_cmd) + ls_result = set(name for name in proc.stdout.splitlines()) + random_files = set( + os.path.basename(name) for name in self.ramdom_files + ) + tools.ok_(random_files.issubset(ls_result)) + tools.eq_(proc.return_code, 0) + proc.raise_for_error() + + # run again + with mock.patch("subprocess.Popen") as mock_p: + proc.block() + tools.eq_(mock_p.mock_calls, []) + + def test_iter_content(self): + # run once + proc = shcmd.run(self.ls_cmd, stream=True) + data = b"".join(d for d in proc.iter_content(100)) + ls_result = set(name for name in data.decode("utf8").splitlines()) + random_files = set( + os.path.basename(name) for name in self.ramdom_files + ) + tools.ok_(random_files.issubset(ls_result)) + tools.eq_(proc.return_code, 0) + proc.raise_for_error() + + # run again + with mock.patch("subprocess.Popen") as mock_p: + for d in proc.iter_content(100): + tools.eq_(len(d), 100) + tools.eq_(mock_p.mock_calls, []) + + def test_iter_lines(self): + # run once + proc = shcmd.run(self.ls_cmd, stream=True) + ls_result = set(name for name in proc.iter_lines()) + random_files = set( + os.path.basename(name) for name in self.ramdom_files + ) + tools.ok_(random_files.issubset(ls_result)) + tools.eq_(proc.return_code, 0) + proc.raise_for_error() + + # run again + with mock.patch("subprocess.Popen") as mock_p: + ls_result = set(name for name in proc.iter_lines()) + random_files = set( + os.path.basename(name) for name in self.ramdom_files + ) + tools.ok_(random_files.issubset(ls_result)) + tools.eq_(mock_p.mock_calls, []) + + @tools.raises(ShCmdError) + @mock.patch("subprocess.Popen.communicate") + def test_error(self, mock_c): + mock_c.return_value = b"stdout", b"stderr" + proc = shcmd.run( + "ls -alh /random-dir/random", + cwd="/", + timeout=1, + env=dict(TEST="1") + ) + tools.eq_(proc.cmd, ["ls", "-alh", "/random-dir/random"]) + tools.eq_(proc.cwd, "/") + tools.eq_(proc.timeout, 1) + tools.eq_(proc.env, {"TEST": "1"}) + + tools.eq_(proc.stdout, "stdout") + tools.eq_(proc.stderr, "stderr") + tools.eq_(proc.content, b"stdout") + + proc.raise_for_error() + + @tools.raises(subprocess.TimeoutExpired) + def test_block_timeout(self): + shcmd.run("grep X", timeout=0.1) + + @tools.raises(subprocess.TimeoutExpired) + def test_stream_timeout(self): + proc = shcmd.run("grep X", timeout=0.1, stream=True) + for data in proc.iter_content(1): + tools.eq_(data, b"") diff --git a/tests/test-utils.py b/tests/test-utils.py new file mode 100644 index 0000000..8a88439 --- /dev/null +++ b/tests/test-utils.py @@ -0,0 +1,50 @@ +# -*- coding: utf8 -*- + +import os +import tempfile + +import nose + +import shcmd.utils + + +def test_asc_expand_args(): + correct = ["/bin/bash", "echo", "上海崇明岛"] + + # str + result = shcmd.utils.expand_args("/bin/bash echo 上海崇明岛") + nose.tools.eq_(result, correct) + + # list + result = shcmd.utils.expand_args(["/bin/bash", "echo", "上海崇明岛"]) + nose.tools.eq_(result, correct) + + # tuple + result = shcmd.utils.expand_args(("/bin/bash", "echo", "上海崇明岛")) + nose.tools.eq_(result, correct) + +""" +def test_cd(): + tmpdir = os.path.realpath(tempfile.gettempdir()) + oridir = os.getcwd() + nose.tools.ok_(oridir != tmpdir, "tmp dir == curr dir") + with shcmd.cmd.cd(tmpdir): + nose.tools.eq_( + os.path.realpath(os.getcwd()), + tmpdir, + "not cd to dir" + ) + nose.tools.eq_(os.getcwd(), oridir, "not cd back") + + +def test_request(): + cwd = os.path.realpath("tmp") + cmd_req = shcmd.cmd.CmdRequest("/bin/bash eval 'ls'", "tmp") + nose.tools.eq_(cmd_req.cmd, ["/bin/bash", "eval", "ls"]) + nose.tools.eq_(cmd_req.raw, "/bin/bash eval 'ls'") + nose.tools.eq_(cmd_req.cwd, cwd) + nose.tools.eq_( + str(cmd_req), + "".format(cwd) + ) +""" From 7e053a5f0e291846174e93baf1fe6e6519bf92ca Mon Sep 17 00:00:00 2001 From: SkyLothar Date: Thu, 9 Apr 2015 23:17:34 +0800 Subject: [PATCH 06/17] add more tests --- MANIFEST.in | 1 + README.rst | 3 +- shcmd/__init__.py | 4 +++ shcmd/request.py | 77 --------------------------------------------- shcmd/response.py | 55 -------------------------------- shcmd/tar.py | 11 +++++-- tests/test-cmd.py | 24 ++++++++++++++ tests/test-tar.py | 52 ++++++++++++++++++++++++++++++ tests/test-utils.py | 29 ----------------- 9 files changed, 92 insertions(+), 164 deletions(-) create mode 100644 MANIFEST.in delete mode 100644 shcmd/request.py delete mode 100644 shcmd/response.py create mode 100644 tests/test-cmd.py create mode 100644 tests/test-tar.py diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..5904a86 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include README.rst LICENSE tests/requirements.txt diff --git a/README.rst b/README.rst index 338e30e..0319da6 100644 --- a/README.rst +++ b/README.rst @@ -16,7 +16,8 @@ If you need piped subprocesses, give envoy_ a try. Usage ^^^^^^ -.. codeblock::python + +.. code-block::python import shcmd diff --git a/shcmd/__init__.py b/shcmd/__init__.py index 3bf963a..b77b66d 100644 --- a/shcmd/__init__.py +++ b/shcmd/__init__.py @@ -5,7 +5,11 @@ import os +__all__ = ["cd", "cd_to", "run", "tar_generator"] + +from .cmd import cd, cd_to from .proc import Proc +from .tar import tar_generator from .utils import expand_args diff --git a/shcmd/request.py b/shcmd/request.py deleted file mode 100644 index a3ab721..0000000 --- a/shcmd/request.py +++ /dev/null @@ -1,77 +0,0 @@ -# -*- coding: utf8 -*- - - -import os -import shlex -import subprocess -import threading - - - - -class CmdRequest(object): - def __init__(self, cmd, cwd=None, timeout=None): - self._cmd = expand_args(cmd) - self._cwd = os.path.realpath(cwd or os.getcwd()) - self._timeout = None - - self._timer = threading.Timer(self.kill, self._timeout) - - def __str__(self): - return "".format(" ".join(self._cmd)) - - @property - def command(self): - return "cd {0} && {1}".format(self.cwd, " ".join(self._cmd)) - - @property - def cwd(self): - return self._cwd - - @property - def timeout(self): - return self._timeout - - def kill(self): - if self._proc is None: - logger.warn("{0} nerver started".format(self)) - elif self._proc.returncode is not None: - logger.warn("{0} ended".format(self)) - else: - self._proc.kill() - - def run(self, block=True): - self._proc = None subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - cwd=cwd - ) - if block: - stdout, stderr = self._proc.communicate(timeout=self.timeout) - return CmdResponse(stdout, stderr, self) - else: - pass - - -class CmdResponse(object): - def __init__(self, request): - self._stdout = stdout - self._stderr = stderr - self._request = request - - @property - def stdout(self): - return self._stdout.decode("utf8") - - @property - def stderr(self): - return self._stderr.decode("utf8") - - @property - def content(self): - return self._stdout - - @property - def request(self): - return self._request diff --git a/shcmd/response.py b/shcmd/response.py deleted file mode 100644 index 5ef2257..0000000 --- a/shcmd/response.py +++ /dev/null @@ -1,55 +0,0 @@ -# -*- coding: utf8 -*- - - -ITER_CHUNK_SIZE = 1024 - - -class CmdResponse(object): - def __init__(self, proc): - self._proc = proc - - @property - def request(self): - return self._proc.request - - @property - def return_code(self): - return self._proc.return_code - - @property - def stdout(self): - return self._proc.stdout - - @property - def stderr(self): - return self._proc.stderr - - def iter_content(self, chunk_size=1): - for data in self._porc.stream(chunk_size): - yield data - - def iter_lines(self, chunk_size=ITER_CHUNK_SIZE, delimiter=None): - pending = None - for chunk in self.iter_content(chunk_size=chunk_size): - if pending is not None: - chunk = pending + chunk - if delimiter: - lines = chunk.split(delimiter) - else: - lines = chunk.splitlines() - - if lines and lines[-1] and lines[-1][-1] == chunk[-1]: - pending = lines.pop() - else: - pending = None - for line in lines: - yield line - if pending is not None: - yield pending - - def raise_for_return_code(self): - if self.ok is False: - pass - - def ok(self): - return self.return_code == 0 diff --git a/shcmd/tar.py b/shcmd/tar.py index 24013ae..38afc6d 100644 --- a/shcmd/tar.py +++ b/shcmd/tar.py @@ -5,7 +5,12 @@ def tar_generator(file_list): """ - :Usage: + generate tar file using giving file list + + :param file_list: a list of file_path or (file_name, file_content) tuple + + Usage:: + >>> tg = tar_generator([ ... "/path/to/a", ... "/path/to/b", @@ -14,7 +19,6 @@ def tar_generator(file_list): >>> len(b"".join(tg)) 1024 - :param file_list: a list of file_path or (file_name, file_content) tuple """ tar_buffer = io.BytesIO() tar_obj = tarfile.TarFile(mode="w", fileobj=tar_buffer, dereference=True) @@ -30,7 +34,10 @@ def tar_generator(file_list): content_info.size = len(content.getvalue()) else: content_info.size = len(content) + if isinstance(content, str): + content = content.encode("utf8") content = io.BytesIO(content) + tar_obj.addfile(content_info, content) tar_buffer.seek(last, os.SEEK_SET) data = tar_buffer.read() diff --git a/tests/test-cmd.py b/tests/test-cmd.py new file mode 100644 index 0000000..c2bc337 --- /dev/null +++ b/tests/test-cmd.py @@ -0,0 +1,24 @@ +# -*- coding: utf8 -*- + +import os +import tempfile + +import nose + +import shcmd.cmd + + +TMPDIR = os.path.realpath(tempfile.gettempdir()) + + +def test_cd(): + oridir = os.getcwd() + nose.tools.ok_(oridir != TMPDIR) + with shcmd.cmd.cd(TMPDIR): + nose.tools.eq_(os.path.realpath(os.getcwd()), TMPDIR) + nose.tools.eq_(os.getcwd(), oridir) + + +@shcmd.cmd.cd_to(TMPDIR) +def test_cd_to(): + nose.tools.eq_(TMPDIR, os.getcwd()) diff --git a/tests/test-tar.py b/tests/test-tar.py new file mode 100644 index 0000000..ccccb16 --- /dev/null +++ b/tests/test-tar.py @@ -0,0 +1,52 @@ +# -*- coding: utf8 -*- + +import io +import os +import tarfile +import tempfile +import uuid + +from nose import tools + +import shcmd.tar + + +class TestRun(object): + def setup(self): + tmp = os.path.realpath(tempfile.gettempdir()) + self.ramdom_files = {} + for __ in range(4): + fname = os.path.join(tmp, uuid.uuid4().hex) + with open(fname, "wb") as f: + content = uuid.uuid4().hex.encode("utf8") + f.write(content) + self.ramdom_files[fname] = content + + def teardown(self): + for fname in self.ramdom_files.keys(): + os.remove(fname) + + def test_tar(self): + extras = [ + ("bytes", b"foo"), + ("str", "bar"), + ("io", io.BytesIO(b"baz")) + ] + file_list = list(self.ramdom_files.keys()) + extras + + tar_data = b"".join(b for b in shcmd.tar.tar_generator(file_list)) + tar_obj = tarfile.open(fileobj=io.BytesIO(tar_data)) + + test_cases = list(self.ramdom_files.items()) + test_cases += [ + ("xbytes", b"foo"), # fake prefix + ("xstr", b"bar"), + ("xio", b"baz") + ] + + for (name, content) in test_cases: + name = name[1:] # tar has not / prefix + info = tar_obj.getmember(name) + e_content = tar_obj.extractfile(info) + tools.eq_(info.name, name) + tools.eq_(e_content.read(), content) diff --git a/tests/test-utils.py b/tests/test-utils.py index 8a88439..1b01c47 100644 --- a/tests/test-utils.py +++ b/tests/test-utils.py @@ -1,8 +1,5 @@ # -*- coding: utf8 -*- -import os -import tempfile - import nose import shcmd.utils @@ -22,29 +19,3 @@ def test_asc_expand_args(): # tuple result = shcmd.utils.expand_args(("/bin/bash", "echo", "上海崇明岛")) nose.tools.eq_(result, correct) - -""" -def test_cd(): - tmpdir = os.path.realpath(tempfile.gettempdir()) - oridir = os.getcwd() - nose.tools.ok_(oridir != tmpdir, "tmp dir == curr dir") - with shcmd.cmd.cd(tmpdir): - nose.tools.eq_( - os.path.realpath(os.getcwd()), - tmpdir, - "not cd to dir" - ) - nose.tools.eq_(os.getcwd(), oridir, "not cd back") - - -def test_request(): - cwd = os.path.realpath("tmp") - cmd_req = shcmd.cmd.CmdRequest("/bin/bash eval 'ls'", "tmp") - nose.tools.eq_(cmd_req.cmd, ["/bin/bash", "eval", "ls"]) - nose.tools.eq_(cmd_req.raw, "/bin/bash eval 'ls'") - nose.tools.eq_(cmd_req.cwd, cwd) - nose.tools.eq_( - str(cmd_req), - "".format(cwd) - ) -""" From 36f9d47ea8ffbe68269b5128691582dda5accfc1 Mon Sep 17 00:00:00 2001 From: Shi Wei Date: Thu, 9 Apr 2015 23:19:11 +0800 Subject: [PATCH 07/17] Update README.rst fix readme --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 0319da6..abb98d8 100644 --- a/README.rst +++ b/README.rst @@ -17,7 +17,7 @@ If you need piped subprocesses, give envoy_ a try. Usage ^^^^^^ -.. code-block::python +.. code-block:: python import shcmd From efe579cad262e22693eb13a40a8e608b3738dbfc Mon Sep 17 00:00:00 2001 From: SkyLothar Date: Thu, 9 Apr 2015 23:20:28 +0800 Subject: [PATCH 08/17] add travis config --- .travis.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..8a59208 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,14 @@ +language: python + +python: + - "3.4" + +install: + - pip install -r tests/requirements.txt + - pip install nose + +script: nosetests --with-coverage --cover-package=shcmd + +after_success: + - pip install coveralls + - coveralls From 36222a10f0cfc10713f33bb37eadb811a9a38ada Mon Sep 17 00:00:00 2001 From: SkyLothar Date: Thu, 9 Apr 2015 23:34:42 +0800 Subject: [PATCH 09/17] fix --- shcmd/proc.py | 4 +++- tests/test-run.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/shcmd/proc.py b/shcmd/proc.py index 098b4bd..5c7f98a 100644 --- a/shcmd/proc.py +++ b/shcmd/proc.py @@ -119,8 +119,10 @@ def _stream(self): def iter_lines(self): remain = "" for data in self.iter_content(LINE_CHUNK_SIZE): - line_break_found = data[:1] in ("\n", "\r") + line_break_found = data[-1] in ("\n", "\r") lines = data.decode(self.codec).splitlines() + if not lines: + continue lines[0] = remain + lines[0] if not line_break_found: remain = lines.pop() diff --git a/tests/test-run.py b/tests/test-run.py index 3d6a356..9db9e54 100644 --- a/tests/test-run.py +++ b/tests/test-run.py @@ -60,7 +60,7 @@ def test_iter_content(self): # run again with mock.patch("subprocess.Popen") as mock_p: for d in proc.iter_content(100): - tools.eq_(len(d), 100) + tools.ok_(len(d) <= 100) tools.eq_(mock_p.mock_calls, []) def test_iter_lines(self): From 47edf5d7675baaf68288fdf73acd704ca477a78a Mon Sep 17 00:00:00 2001 From: SkyLothar Date: Thu, 9 Apr 2015 23:48:15 +0800 Subject: [PATCH 10/17] add docstring --- shcmd/cmd.py | 21 +++++++++++++++++++++ shcmd/proc.py | 38 ++++++++++++++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/shcmd/cmd.py b/shcmd/cmd.py index 544c8a0..39d1252 100644 --- a/shcmd/cmd.py +++ b/shcmd/cmd.py @@ -7,6 +7,15 @@ @contextlib.contextmanager def cd(cd_path): + """cd to target dir when running in this block + + :param cd_path: dir to cd into + + Usage:: + + >>> with cd("/tmp"): + ... print("we are in /tmp now") + """ oricwd = os.getcwd() try: os.chdir(cd_path) @@ -16,6 +25,18 @@ def cd(cd_path): def cd_to(path): + """make a generator like cd, but use it for function + + Usage:: + + >>> @cd_to("/") + ... def say_where(): + ... print(os.getcwd()) + ... + >>> say_where() + / + + """ def cd_to_decorator(func): @functools.wraps(func) def _cd_and_exec(*args, **kwargs): diff --git a/shcmd/proc.py b/shcmd/proc.py index 5c7f98a..7b39162 100644 --- a/shcmd/proc.py +++ b/shcmd/proc.py @@ -33,6 +33,22 @@ class Proc(object): codec = "utf8" def __init__(self, cmd, cwd, env, timeout): + """ + :param cmd: the command + :param cwd: the command should be running under `cwd` dir + :param env: the environment variable + :param timeout: the command should return in `timeout` seconds + + Usage:: + + >>> p = Proc("ls", "/", timeout=1) + >>> p.block() + >>> p.ok + True + >>> type(p.stdout) + + + """ self._cmd = cmd self._cwd = cwd self._env = env @@ -41,18 +57,22 @@ def __init__(self, cmd, cwd, env, timeout): @property def cmd(self): + """the proc's command.""" return self._cmd[:] @property def cwd(self): + """the proc's execuation dir.""" return self._cwd @property def env(self): + """the proc's environment setting.""" return self._env.copy() @property def timeout(self): + """the proc's timeout setting.""" return self._timeout @property @@ -67,17 +87,21 @@ def stderr(self): @property def return_code(self): + """proc's return_code""" return self._return_code @property def content(self): + """the output gathered in stdout in bytes format""" return self._stdout @property def ok(self): + """`True` if proc's return_code is 0""" return self.return_code == 0 def raise_for_error(self): + """raise `ShCmdError` if the proc's return_code is not 0""" if not self.ok: tip = "running {0} @<{1}> error, return code {2}".format( " ".join(self.cmd), self.cwd, self.return_code @@ -89,7 +113,7 @@ def raise_for_error(self): @contextlib.contextmanager def _stream(self): - """Execute subprocess with timeout + """execute subprocess with timeout Usage:: @@ -117,12 +141,13 @@ def _stream(self): timer.cancel() def iter_lines(self): + """yields stdout text, line by line.""" remain = "" for data in self.iter_content(LINE_CHUNK_SIZE): - line_break_found = data[-1] in ("\n", "\r") - lines = data.decode(self.codec).splitlines() - if not lines: + if not data: continue + line_break_found = data[-1] in (b"\n", b"\r") + lines = data.decode(self.codec).splitlines() lines[0] = remain + lines[0] if not line_break_found: remain = lines.pop() @@ -130,6 +155,11 @@ def iter_lines(self): yield line def iter_content(self, chunk_size=1): + """ + yields stdout data, chunk by chunk + + :param chunk_size: size of each chunk (in bytes) + """ if self.return_code is not None: stdout = io.BytesIO(self._stdout) data = stdout.read(chunk_size) From 27593a35d3f8ffb7fc08ceea807179fa651ec98c Mon Sep 17 00:00:00 2001 From: SkyLothar Date: Fri, 10 Apr 2015 01:21:57 +0800 Subject: [PATCH 11/17] fix tests --- shcmd/proc.py | 3 +-- tests/test-run.py | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/shcmd/proc.py b/shcmd/proc.py index 7b39162..8869c71 100644 --- a/shcmd/proc.py +++ b/shcmd/proc.py @@ -144,8 +144,6 @@ def iter_lines(self): """yields stdout text, line by line.""" remain = "" for data in self.iter_content(LINE_CHUNK_SIZE): - if not data: - continue line_break_found = data[-1] in (b"\n", b"\r") lines = data.decode(self.codec).splitlines() lines[0] = remain + lines[0] @@ -183,6 +181,7 @@ def iter_content(self, chunk_size=1): chunk = proc.stdout.read(chunk_size) while chunk: yield chunk + data += chunk chunk = proc.stdout.read(chunk_size) self._return_code = proc.returncode diff --git a/tests/test-run.py b/tests/test-run.py index 9db9e54..3427219 100644 --- a/tests/test-run.py +++ b/tests/test-run.py @@ -78,7 +78,8 @@ def test_iter_lines(self): with mock.patch("subprocess.Popen") as mock_p: ls_result = set(name for name in proc.iter_lines()) random_files = set( - os.path.basename(name) for name in self.ramdom_files + os.path.basename(name) + for name in self.ramdom_files ) tools.ok_(random_files.issubset(ls_result)) tools.eq_(mock_p.mock_calls, []) From dca2df1657e45f22a82197290f4469598a6333be Mon Sep 17 00:00:00 2001 From: SkyLothar Date: Fri, 10 Apr 2015 01:26:04 +0800 Subject: [PATCH 12/17] add log --- tests/test-run.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test-run.py b/tests/test-run.py index 3427219..6bd2c11 100644 --- a/tests/test-run.py +++ b/tests/test-run.py @@ -70,6 +70,7 @@ def test_iter_lines(self): random_files = set( os.path.basename(name) for name in self.ramdom_files ) + print(random_files, ls_result) tools.ok_(random_files.issubset(ls_result)) tools.eq_(proc.return_code, 0) proc.raise_for_error() From 3d2be3357ee6a179d7c2d4c6255c5659efd02966 Mon Sep 17 00:00:00 2001 From: SkyLothar Date: Fri, 10 Apr 2015 01:31:35 +0800 Subject: [PATCH 13/17] add print. for travis debug --- shcmd/proc.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/shcmd/proc.py b/shcmd/proc.py index 8869c71..e116a79 100644 --- a/shcmd/proc.py +++ b/shcmd/proc.py @@ -144,6 +144,10 @@ def iter_lines(self): """yields stdout text, line by line.""" remain = "" for data in self.iter_content(LINE_CHUNK_SIZE): + print("#"*20) + print(data) + print("#"*20) + line_break_found = data[-1] in (b"\n", b"\r") lines = data.decode(self.codec).splitlines() lines[0] = remain + lines[0] From 9f7ac348b1965b98717724c38ccffef6d4f27dcb Mon Sep 17 00:00:00 2001 From: SkyLothar Date: Fri, 10 Apr 2015 01:39:18 +0800 Subject: [PATCH 14/17] try to fix test error --- shcmd/proc.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/shcmd/proc.py b/shcmd/proc.py index e116a79..80e67f6 100644 --- a/shcmd/proc.py +++ b/shcmd/proc.py @@ -144,10 +144,6 @@ def iter_lines(self): """yields stdout text, line by line.""" remain = "" for data in self.iter_content(LINE_CHUNK_SIZE): - print("#"*20) - print(data) - print("#"*20) - line_break_found = data[-1] in (b"\n", b"\r") lines = data.decode(self.codec).splitlines() lines[0] = remain + lines[0] @@ -155,6 +151,8 @@ def iter_lines(self): remain = lines.pop() for line in lines: yield line + if remain: + yield remain def iter_content(self, chunk_size=1): """ From 25c5e44ceba0d1cc1473255a16b7fde57c27de4f Mon Sep 17 00:00:00 2001 From: SkyLothar Date: Fri, 10 Apr 2015 01:43:46 +0800 Subject: [PATCH 15/17] print error --- shcmd/proc.py | 1 + 1 file changed, 1 insertion(+) diff --git a/shcmd/proc.py b/shcmd/proc.py index 80e67f6..ed81c36 100644 --- a/shcmd/proc.py +++ b/shcmd/proc.py @@ -144,6 +144,7 @@ def iter_lines(self): """yields stdout text, line by line.""" remain = "" for data in self.iter_content(LINE_CHUNK_SIZE): + print(repr(data).center(50, "x")) line_break_found = data[-1] in (b"\n", b"\r") lines = data.decode(self.codec).splitlines() lines[0] = remain + lines[0] From df6d39ba6de91c9c67322395a14dffd613507493 Mon Sep 17 00:00:00 2001 From: SkyLothar Date: Fri, 10 Apr 2015 02:19:57 +0800 Subject: [PATCH 16/17] print more --- shcmd/proc.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shcmd/proc.py b/shcmd/proc.py index ed81c36..476c03f 100644 --- a/shcmd/proc.py +++ b/shcmd/proc.py @@ -173,6 +173,7 @@ def iter_content(self, chunk_size=1): with self._stream() as proc: while proc.poll() is None: chunk = proc.stdout.read(chunk_size) + print("chunk is {0}".format(repr(chunk))) yield chunk data += chunk @@ -184,6 +185,7 @@ def iter_content(self, chunk_size=1): chunk = proc.stdout.read(chunk_size) while chunk: yield chunk + print("end block chunk is {0}".format(repr(chunk))) data += chunk chunk = proc.stdout.read(chunk_size) From 6f7429e9d59e8f41295258ce052d3476390e6f95 Mon Sep 17 00:00:00 2001 From: SkyLothar Date: Fri, 10 Apr 2015 02:25:20 +0800 Subject: [PATCH 17/17] fix index error --- shcmd/proc.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/shcmd/proc.py b/shcmd/proc.py index 476c03f..6ef71f9 100644 --- a/shcmd/proc.py +++ b/shcmd/proc.py @@ -144,7 +144,6 @@ def iter_lines(self): """yields stdout text, line by line.""" remain = "" for data in self.iter_content(LINE_CHUNK_SIZE): - print(repr(data).center(50, "x")) line_break_found = data[-1] in (b"\n", b"\r") lines = data.decode(self.codec).splitlines() lines[0] = remain + lines[0] @@ -173,7 +172,8 @@ def iter_content(self, chunk_size=1): with self._stream() as proc: while proc.poll() is None: chunk = proc.stdout.read(chunk_size) - print("chunk is {0}".format(repr(chunk))) + if not chunk: + continue yield chunk data += chunk @@ -185,7 +185,6 @@ def iter_content(self, chunk_size=1): chunk = proc.stdout.read(chunk_size) while chunk: yield chunk - print("end block chunk is {0}".format(repr(chunk))) data += chunk chunk = proc.stdout.read(chunk_size)