changeset 759:dd03385adb62

publish: Add SFTP publisher.
author Ludovic Chabant <ludovic@chabant.com>
date Sat, 25 Jun 2016 17:03:43 -0700
parents 6abb436fea5b
children 3cea11696a9e
files piecrust/publishing/sftp.py requirements.txt
diffstat 2 files changed, 149 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/piecrust/publishing/sftp.py	Sat Jun 25 17:03:43 2016 -0700
@@ -0,0 +1,148 @@
+import os
+import os.path
+import socket
+import urllib.parse
+import getpass
+import logging
+import paramiko
+from piecrust.publishing.base import Publisher, PublisherConfigurationError
+
+
+logger = logging.getLogger(__name__)
+
+
+class SftpPublisher(Publisher):
+    PUBLISHER_NAME = 'sftp'
+    PUBLISHER_SCHEME = 'sftp'
+
+    def setupPublishParser(self, parser, app):
+        parser.add_argument(
+                '--force',
+                action='store_true',
+                help=("Upload the entire bake directory instead of only "
+                      "the files changed by the last bake."))
+
+    def run(self, ctx):
+        remote = self.config
+        if not self.has_url_config:
+            host = self.getConfigValue('host')
+            if not host:
+                raise PublisherConfigurationError(
+                        "Publish target '%s' doesn't specify a 'host'." %
+                        self.target)
+            remote = urllib.parse.urlparse(host)
+
+        hostname = remote.hostname
+        port = remote.port or 22
+        path = remote.path
+        if not hostname:
+            hostname = path
+            path = ''
+
+        username = remote.username
+        pkey_path = None
+
+        if not self.has_url_config:
+            if not username:
+                username = self.getConfigValue('username')
+            if not path:
+                path = self.getConfigValue('path')
+
+            pkey_path = self.getConfigValue('key')
+
+        password = None
+        if username:
+            password = getpass.getpass("Password for '%s': " % username)
+
+        logger.debug("Connecting to %s:%s..." % (hostname, port))
+        lfk = (not username and not pkey_path)
+        sshc = paramiko.SSHClient()
+        sshc.load_system_host_keys()
+        sshc.set_missing_host_key_policy(paramiko.WarningPolicy())
+        sshc.connect(
+                hostname, port=port,
+                username=username, password=password,
+                key_filename=pkey_path,
+                look_for_keys=lfk)
+        try:
+            logger.info("Connected as %s" %
+                        sshc.get_transport().get_username())
+            client = sshc.open_sftp()
+            try:
+                self._upload(sshc, client, ctx, path)
+            finally:
+                client.close()
+        finally:
+            sshc.close()
+
+    def _upload(self, session, client, ctx, dest_dir):
+        if dest_dir:
+            if dest_dir.startswith('~/'):
+                _, out_chan, _ = session.exec_command("echo $HOME")
+                home_dir = out_chan.read().decode('utf8').strip()
+                dest_dir = home_dir + dest_dir[1:]
+            logger.debug("CHDIR %s" % dest_dir)
+            try:
+                client.chdir(dest_dir)
+            except IOError:
+                client.mkdir(dest_dir)
+                client.chdir(dest_dir)
+
+        known_dirs = {}
+        if ctx.was_baked and not ctx.args.force:
+            to_upload = list(self.getBakedFiles(ctx))
+            to_delete = list(self.getDeletedFiles(ctx))
+            if to_upload or to_delete:
+                logger.info("Uploading new/changed files...")
+                for path in self.getBakedFiles(ctx):
+                    rel_path = os.path.relpath(path, ctx.bake_out_dir)
+                    logger.info(rel_path)
+                    if not ctx.preview:
+                        self._putFile(client, path, rel_path, known_dirs)
+                logger.info("Deleting removed files...")
+                for path in self.getDeletedFiles(ctx):
+                    rel_path = os.path.relpath(path, ctx.bake_out_dir)
+                    logger.info("%s [DELETE]" % rel_path)
+                    if not ctx.preview:
+                        try:
+                            client.remove(rel_path)
+                        except OSError:
+                            pass
+            else:
+                logger.info("Nothing to upload or delete on the remote server.")
+                logger.info("If you want to force uploading the entire website, "
+                            "use the `--force` flag.")
+        else:
+            logger.info("Uploading entire website...")
+            for dirpath, dirnames, filenames in os.walk(ctx.bake_out_dir):
+                for f in filenames:
+                    abs_f = os.path.join(dirpath, f)
+                    rel_f = os.path.relpath(abs_f, ctx.bake_out_dir)
+                    logger.info(rel_f)
+                    if not ctx.preview:
+                        self._putFile(client, abs_f, rel_f, known_dirs)
+
+    def _putFile(self, client, local_path, remote_path, known_dirs):
+        # Split the remote path in bits.
+        remote_path = os.path.normpath(remote_path)
+        if os.sep != '/':
+            remote_path = remote_path.sub(os.sep, '/')
+
+        # Make sure each directory in the remote path exists... to prevent
+        # testing the same directories several times, we keep a cache of
+        # `known_dirs` which we know exist.
+        remote_bits = remote_path.split('/')
+        cur = ''
+        for b in remote_bits[:-1]:
+            cur = os.path.join(cur, b)
+            if cur not in known_dirs:
+                try:
+                    info = client.stat(cur)
+                except FileNotFoundError:
+                    logger.debug("Creating remote dir: %s" % cur)
+                    client.mkdir(cur)
+                known_dirs[cur] = True
+
+        # Should be all good! Upload the file.
+        client.put(local_path, remote_path)
+
--- a/requirements.txt	Sat Jun 25 17:03:29 2016 -0700
+++ b/requirements.txt	Sat Jun 25 17:03:43 2016 -0700
@@ -6,6 +6,7 @@
 Jinja2==2.7.3
 Markdown==2.6.2
 MarkupSafe==0.23
+paramiko==2.0.0
 Pygments==2.0.2
 pystache==0.5.4
 python-dateutil==2.4.2