Permalink
Browse files

Initial commit

  • Loading branch information...
0 parents commit f0ef749dd3b9e3476804a3961a9540801dba319e @mwilliamson committed Nov 26, 2012
Showing with 307 additions and 0 deletions.
  1. +26 −0 LICENSE
  2. +2 −0 MANIFEST.in
  3. +2 −0 README.md
  4. +11 −0 makefile
  5. +2 −0 requirements.txt
  6. +18 −0 setup.py
  7. +4 −0 spur/__init__.py
  8. +23 −0 spur/files.py
  9. +49 −0 spur/local.py
  10. +159 −0 spur/ssh.py
  11. +11 −0 spur/tempdir.py
26 LICENSE
@@ -0,0 +1,26 @@
+Copyright (c) 2012, Michael Williamson
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+The views and conclusions contained in the software and documentation are those
+of the authors and should not be interpreted as representing official policies,
+either expressed or implied, of the FreeBSD Project.
@@ -0,0 +1,2 @@
+include README
+include README.md
@@ -0,0 +1,2 @@
+# spur.py: Run commands and manipulate files locally or over SSH using the same interface
+
@@ -0,0 +1,11 @@
+.PHONY: test
+
+test:
+ nosetests -m'^$$' `find tests -name '*.py'`
+
+upload:
+ pandoc --from=markdown --to=rst README.md > README
+ python setup.py sdist upload
+ rm README
+ rm MANIFEST
+ rm -r dist
@@ -0,0 +1,2 @@
+nose==1.2.1
+paramiko==1.8.0
@@ -0,0 +1,18 @@
+#!/usr/bin/env python
+
+import os
+from distutils.core import setup
+
+def read(fname):
+ return open(os.path.join(os.path.dirname(__file__), fname)).read()
+
+setup(
+ name='spur',
+ version='0.1.0',
+ description='Run commands and manipulate files locally or over SSH using the same interface',
+ long_description=read("README"),
+ author='Michael Williamson',
+ url='http://github.com/mwilliamson/spur.py',
+ packages=['spur'],
+ install_requires=["paramiko==1.8.0"],
+)
@@ -0,0 +1,4 @@
+from spur.local import LocalShell
+from spur.ssh import SshShell
+
+__all__ = ["LocalShell", "SshShell"]
@@ -0,0 +1,23 @@
+import os
+
+class FileOperations(object):
+ def __init__(self, shell):
+ self._shell = shell
+
+ def copy_file(self, source, destination=None, dir=None):
+ if destination is None and dir is None:
+ raise TypeError("Destination required for copy")
+
+ if destination is not None:
+ self._shell.run(["cp", "-T", source, destination])
+ elif dir is not None:
+ self._shell.run(["cp", source, "-t", dir])
+
+ def write_file(self, path, contents):
+ self._shell.run(["mkdir", "-p", os.path.dirname(path)])
+ file = self._shell.open(path, "w")
+ try:
+ file.write(contents)
+ finally:
+ file.close()
+
@@ -0,0 +1,49 @@
+import os
+import subprocess
+import shutil
+
+from spur.tempdir import create_temporary_dir
+from spur.files import FileOperations
+
+class LocalShell(object):
+ def upload_dir(self, source, dest, ignore=None):
+ shutil.copytree(source, dest, ignore=shutil.ignore_patterns(*ignore))
+
+ def upload_file(self, source, dest):
+ shutil.copyfile(source, dest)
+
+ def open(self, name, mode):
+ return open(name, mode)
+
+ def write_file(self, remote_path, contents):
+ subprocess.check_call(["mkdir", "-p", os.path.dirname(remote_path)])
+ open(remote_path, "w").write(contents)
+
+ def spawn(self, *args, **kwargs):
+ subprocess.Popen(**self._subprocess_args(*args, **kwargs))
+
+ def run(self, *args, **kwargs):
+ output = subprocess.check_output(**self._subprocess_args(*args, **kwargs))
+ return ExecutionResult(output)
+
+ def temporary_dir(self):
+ return create_temporary_dir()
+
+ @property
+ def files(self):
+ return FileOperations(self)
+
+ def _subprocess_args(self, command, cwd=None, update_env=None, new_process_group=False):
+ kwargs = {"args": command, "cwd": cwd}
+ if update_env is not None:
+ new_env = os.environ.copy()
+ new_env.update(update_env)
+ kwargs["env"] = new_env
+ if new_process_group:
+ kwargs["preexec_fn"] = os.setpgrp
+ return kwargs
+
+class ExecutionResult(object):
+ def __init__(self, output):
+ self.output = output
+
@@ -0,0 +1,159 @@
+import subprocess
+import os
+import os.path
+import shutil
+import contextlib
+import uuid
+import time
+
+import paramiko
+
+from spur.tempdir import create_temporary_dir
+from spur.files import FileOperations
+
+class SshShell(object):
+ def __init__(self, hostname, username, password=None, port=22, private_key=None):
+ self._hostname = hostname
+ self._port = port
+ self._username = username
+ self._password = password
+ self._private_key = private_key
+ self._client = None
+
+ def run(self, *args, **kwargs):
+ start = time.time()
+ command_in_cwd = self._generate_run_command(*args, **kwargs)
+
+ with self._connect_ssh() as client:
+ stdin, stdout, stderr = client.exec_command(command_in_cwd)
+ output = []
+ for line in stdout:
+ output.append(line)
+
+ stderr_output = []
+ for line in stderr:
+ stderr_output.append(line)
+
+ return_code = int(output[-1])
+ end = time.time()
+ print command_in_cwd
+ print "time taken:", (end - start)
+ if return_code == 0:
+ return ExecutionResult("".join(output[:-1]))
+ else:
+ print "ERR:", "".join(stderr_output)
+ print "OUT:", "".join(output[:-1])
+ print "RETURN: ", return_code
+ raise subprocess.CalledProcessError(return_code, command_in_cwd)
+
+ def spawn(self, *args, **kwargs):
+ command_in_cwd = self._generate_run_command(*args, **kwargs)
+ print "RUN: ", command_in_cwd
+ with self._connect_ssh() as client:
+ client.exec_command(command_in_cwd)
+
+ @contextlib.contextmanager
+ def temporary_dir(self):
+ result = self.run(["mktemp", "--directory"])
+ temp_dir = result.output.strip()
+ try:
+ yield temp_dir
+ finally:
+ self.run(["rm", "-rf", temp_dir])
+
+ def _generate_run_command(self, command_args, cwd="/", update_env={}, new_process_group=False):
+ command = " ".join(map(escape_sh, command_args))
+
+ update_env_commands = " ".join([
+ "export {0}={1};".format(key, escape_sh(value))
+ for key, value in update_env.iteritems()
+ ])
+
+ if new_process_group:
+ command = "setsid {0}".format(command)
+
+ return r"cd {0}; {1} {2}; echo -e \\n$?".format(cwd, update_env_commands, command)
+
+
+ def upload_dir(self, local_dir, remote_dir, ignore):
+ with create_temporary_dir() as temp_dir:
+ content_tarball_path = os.path.join(temp_dir, "content.tar.gz")
+ content_path = os.path.join(temp_dir, "content")
+ shutil.copytree(local_dir, content_path, ignore=shutil.ignore_patterns(*ignore))
+ subprocess.check_call(
+ ["tar", "czf", content_tarball_path, "content"],
+ cwd=temp_dir
+ )
+ with self._connect_sftp() as sftp:
+ remote_tarball_path = "/tmp/{0}.tar.gz".format(uuid.uuid4())
+ sftp.put(content_tarball_path, remote_tarball_path)
+ self.run(["mkdir", "-p", remote_dir])
+ self.run([
+ "tar", "xzf", remote_tarball_path,
+ "--strip-components", "1", "--directory", remote_dir
+ ])
+
+ sftp.remove(remote_tarball_path)
+
+ def open(self, name, mode):
+ with self._connect_ssh() as client:
+ sftp = client.open_sftp()
+ return SftpFile(sftp, sftp.open(name, mode))
+
+ @property
+ def files(self):
+ return FileOperations(self)
+
+ @contextlib.contextmanager
+ def _connect_ssh(self):
+ if self._client is None:
+ client = paramiko.SSHClient()
+ client.load_system_host_keys()
+ client.set_missing_host_key_policy(paramiko.WarningPolicy())
+ client.connect(
+ hostname=self._hostname,
+ port=self._port,
+ username=self._username,
+ password=self._password,
+ key_filename=self._private_key
+ )
+ self._client = client
+ yield self._client
+
+ @contextlib.contextmanager
+ def _connect_sftp(self):
+ with self._connect_ssh() as client:
+ sftp = client.open_sftp()
+ try:
+ yield sftp
+ finally:
+ sftp.close()
+
+
+class SftpFile(object):
+ def __init__(self, sftp, file):
+ self._sftp = sftp
+ self._file = file
+
+ def __getattr__(self, key):
+ if hasattr(self._file, key):
+ return getattr(self._file, key)
+ raise AttributeError
+
+ def close(self):
+ try:
+ self._file.close()
+ finally:
+ self._sftp.close()
+
+ def __exit__(self, *args):
+ self.close()
+
+
+class ExecutionResult(object):
+ def __init__(self, output):
+ self.output = output
+
+
+def escape_sh(value):
+ return "'" + value.replace("'", "'\\''") + "'"
@@ -0,0 +1,11 @@
+import contextlib
+import tempfile
+import shutil
+
+@contextlib.contextmanager
+def create_temporary_dir():
+ dir = tempfile.mkdtemp()
+ try:
+ yield dir
+ finally:
+ shutil.rmtree(dir)

0 comments on commit f0ef749

Please sign in to comment.