changeset 885:13e8b50a2113

publish: Fix publishers API and add a simple "copy" publisher.
author Ludovic Chabant <ludovic@chabant.com>
date Tue, 20 Jun 2017 21:12:35 -0700
parents 18b3e2acd069
children dcdec4b951a1
files piecrust/app.py piecrust/commands/builtin/publishing.py piecrust/plugins/builtin.py piecrust/publishing/base.py piecrust/publishing/copy.py piecrust/publishing/publisher.py piecrust/publishing/rsync.py piecrust/publishing/sftp.py piecrust/publishing/shell.py piecrust/sources/mixins.py
diffstat 10 files changed, 279 insertions(+), 252 deletions(-) [+]
line wrap: on
line diff
--- a/piecrust/app.py	Tue Jun 20 21:10:39 2017 -0700
+++ b/piecrust/app.py	Tue Jun 20 21:12:35 2017 -0700
@@ -178,20 +178,25 @@
         publish_config = self.config.get('publish')
         if publish_config is None:
             return tgts
+
         for n, t in publish_config.items():
-            pub_type = None
-            is_scheme = False
+            pub_class = None
             if isinstance(t, dict):
                 pub_type = t.get('type')
+                pub_class = defs_by_name[pub_type]
+                pub_cfg = t
             elif isinstance(t, str):
                 comps = urllib.parse.urlparse(t)
                 pub_type = comps.scheme
-                is_scheme = True
-            cls = (defs_by_scheme.get(pub_type) if is_scheme
-                   else defs_by_name.get(pub_type))
-            if cls is None:
+                pub_class = defs_by_scheme[pub_type]
+                pub_cfg = None
+            if pub_class is None:
                 raise ConfigurationError("No such publisher: %s" % pub_type)
-            tgt = cls(self, n, t)
+
+            tgt = pub_class(self, n, pub_cfg)
+            if pub_cfg is None:
+                tgt.parseUrlTarget(comps)
+
             tgts.append(tgt)
         return tgts
 
--- a/piecrust/commands/builtin/publishing.py	Tue Jun 20 21:10:39 2017 -0700
+++ b/piecrust/commands/builtin/publishing.py	Tue Jun 20 21:12:35 2017 -0700
@@ -55,14 +55,12 @@
         ctx.args.sub_func(ctx)
 
     def _doPublish(self, ctx):
-        from piecrust.publishing.publisher import Publisher
+        from piecrust.publishing.base import PublishingManager
 
-        pub = Publisher(ctx.app)
+        pub = PublishingManager(ctx.appfactory, ctx.app)
         pub.run(
             ctx.args.target,
             preview=ctx.args.preview,
             extra_args=ctx.args,
-            log_file=ctx.args.log_publisher,
-            applied_config_variant=ctx.config_variant,
-            applied_config_values=ctx.config_values)
+            log_file=ctx.args.log_publisher)
 
--- a/piecrust/plugins/builtin.py	Tue Jun 20 21:10:39 2017 -0700
+++ b/piecrust/plugins/builtin.py	Tue Jun 20 21:12:35 2017 -0700
@@ -152,11 +152,13 @@
             WordpressXmlImporter()]
 
     def getPublishers(self):
+        from piecrust.publishing.copy import CopyPublisher
         from piecrust.publishing.sftp import SftpPublisher
         from piecrust.publishing.shell import ShellCommandPublisher
         from piecrust.publishing.rsync import RsyncPublisher
 
         return [
+            CopyPublisher,
             ShellCommandPublisher,
             SftpPublisher,
             RsyncPublisher]
--- a/piecrust/publishing/base.py	Tue Jun 20 21:10:39 2017 -0700
+++ b/piecrust/publishing/base.py	Tue Jun 20 21:12:35 2017 -0700
@@ -1,10 +1,7 @@
 import os.path
-import shlex
-import urllib.parse
+import time
 import logging
-import threading
-import subprocess
-from piecrust.configuration import try_get_dict_value
+from piecrust.chefutil import format_timed
 
 
 logger = logging.getLogger(__name__)
@@ -18,17 +15,17 @@
     pass
 
 
-class PublishingContext(object):
+class PublishingContext:
     def __init__(self):
         self.bake_out_dir = None
-        self.bake_record = None
+        self.bake_records = None
         self.processing_record = None
         self.was_baked = False
         self.preview = False
         self.args = None
 
 
-class Publisher(object):
+class Publisher:
     PUBLISHER_NAME = 'undefined'
     PUBLISHER_SCHEME = None
 
@@ -36,90 +33,129 @@
         self.app = app
         self.target = target
         self.config = config
-        self.has_url_config = isinstance(config, urllib.parse.ParseResult)
         self.log_file_path = None
 
     def setupPublishParser(self, parser, app):
         return
 
-    def getConfigValue(self, name, default_value=None):
-        if self.has_url_config:
-            raise Exception("This publisher only has a URL configuration.")
-        return try_get_dict_value(self.config, name, default=default_value)
+    def parseUrlTarget(self, url):
+        raise NotImplementedError()
 
     def run(self, ctx):
         raise NotImplementedError()
 
     def getBakedFiles(self, ctx):
-        for e in ctx.bake_record.entries:
-            for sub in e.subs:
-                if sub.was_baked:
-                    yield sub.out_path
-        for e in ctx.processing_record.entries:
-            if e.was_processed:
-                yield from [os.path.join(ctx.processing_record.out_dir, p)
-                        for p in e.rel_outputs]
+        for rec in ctx.bake_records.records:
+            for e in rec.getEntries():
+                paths = e.getAllOutputPaths()
+                if paths is not None:
+                    yield from paths
 
     def getDeletedFiles(self, ctx):
-        yield from ctx.bake_record.deleted
-        yield from ctx.processing_record.deleted
+        for rec in ctx.bake_records.records:
+            yield from rec.deleted_out_paths
+
+
+class InvalidPublishTargetError(Exception):
+    pass
+
+
+class PublishingError(Exception):
+    pass
 
 
-class ShellCommandPublisherBase(Publisher):
-    def __init__(self, app, target, config):
-        super(ShellCommandPublisherBase, self).__init__(app, target, config)
-        self.expand_user_args = True
+class PublishingManager:
+    def __init__(self, appfactory, app):
+        self.appfactory = appfactory
+        self.app = app
+
+    def run(self, target,
+            force=False, preview=False, extra_args=None, log_file=None):
+        start_time = time.perf_counter()
+
+        # Get publisher for this target.
+        pub = self.app.getPublisher(target)
+        if pub is None:
+            raise InvalidPublishTargetError(
+                "No such publish target: %s" % target)
+
+        # Will we need to bake first?
+        bake_first = pub.config.get('bake', True)
 
-    def run(self, ctx):
-        args = self._getCommandArgs(ctx)
-        if self.expand_user_args:
-            args = [os.path.expanduser(i) for i in args]
+        # Setup logging stuff.
+        hdlr = None
+        root_logger = logging.getLogger()
+        if log_file and not preview:
+            logger.debug("Adding file handler for: %s" % log_file)
+            hdlr = logging.FileHandler(log_file, mode='w', encoding='utf8')
+            root_logger.addHandler(hdlr)
+        if not preview:
+            logger.info("Deploying to %s" % target)
+        else:
+            logger.info("Previewing deployment to %s" % target)
 
-        if ctx.preview:
-            preview_args = ' '.join([shlex.quote(i) for i in args])
-            logger.info(
-                    "Would run shell command: %s" % preview_args)
-            return True
-
-        logger.debug(
-                "Running shell command: %s" % args)
+        # Bake first is necessary.
+        records = None
+        was_baked = False
+        bake_out_dir = os.path.join(self.app.root_dir, '_pub', target)
+        if bake_first:
+            if not preview:
+                bake_start_time = time.perf_counter()
+                logger.debug("Baking first to: %s" % bake_out_dir)
 
-        proc = subprocess.Popen(
-                args, cwd=self.app.root_dir, bufsize=0,
-                stdout=subprocess.PIPE)
+                from piecrust.baking.baker import Baker
+                baker = Baker(
+                    self.appfactory, self.app, bake_out_dir, force=force)
+                records = baker.bake()
+                was_baked = True
 
-        logger.debug("Running publishing monitor for PID %d" % proc.pid)
-        thread = _PublishThread(proc)
-        thread.start()
-        proc.wait()
-        thread.join()
+                if not records.success:
+                    raise Exception(
+                        "Error during baking, aborting publishing.")
+                logger.info(format_timed(bake_start_time, "Baked website."))
+            else:
+                logger.info("Would bake to: %s" % bake_out_dir)
+
+        # Publish!
+        logger.debug(
+            "Running publish target '%s' with publisher: %s" %
+            (target, pub.PUBLISHER_NAME))
+        pub_start_time = time.perf_counter()
 
-        if proc.returncode != 0:
-            logger.error(
-                    "Publish process returned code %d" % proc.returncode)
-        else:
-            logger.debug("Publish process returned successfully.")
+        ctx = PublishingContext()
+        ctx.bake_out_dir = bake_out_dir
+        ctx.bake_records = records
+        ctx.was_baked = was_baked
+        ctx.preview = preview
+        ctx.args = extra_args
+        try:
+            pub.run(ctx)
+        except Exception as ex:
+            raise PublishingError(
+                "Error publishing to target: %s" % target) from ex
+        finally:
+            if hdlr:
+                root_logger.removeHandler(hdlr)
+                hdlr.close()
 
-        return proc.returncode == 0
+        logger.info(format_timed(
+            pub_start_time, "Ran publisher %s" % pub.PUBLISHER_NAME))
 
-    def _getCommandArgs(self, ctx):
-        raise NotImplementedError()
+        logger.info(format_timed(start_time, 'Deployed to %s' % target))
 
 
-class _PublishThread(threading.Thread):
-    def __init__(self, proc):
-        super(_PublishThread, self).__init__(
-                name='publish_monitor', daemon=True)
-        self.proc = proc
-        self.root_logger = logging.getLogger()
+def find_publisher_class(app, name, is_scheme=False):
+    attr_name = 'PUBLISHER_SCHEME' if is_scheme else 'PUBLISHER_NAME'
+    for pub_cls in app.plugin_loader.getPublishers():
+        pub_sch = getattr(pub_cls, attr_name, None)
+        if pub_sch == name:
+            return pub_cls
+    return None
 
-    def run(self):
-        for line in iter(self.proc.stdout.readline, b''):
-            line_str = line.decode('utf8')
-            logger.info(line_str.rstrip('\r\n'))
-            for h in self.root_logger.handlers:
-                h.flush()
 
-        self.proc.communicate()
-        logger.debug("Publish monitor exiting.")
+def find_publisher_name(app, scheme):
+    pub_cls = find_publisher_class(app, scheme, True)
+    if pub_cls:
+        return pub_cls.PUBLISHER_NAME
+    return None
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/piecrust/publishing/copy.py	Tue Jun 20 21:12:35 2017 -0700
@@ -0,0 +1,51 @@
+import os
+import os.path
+import shutil
+import logging
+from piecrust.publishing.base import Publisher
+
+
+logger = logging.getLogger(__name__)
+
+
+class CopyPublisher(Publisher):
+    PUBLISHER_NAME = 'copy'
+    PUBLISHER_SCHEME = 'file'
+
+    def parseUrlTarget(self, url):
+        self.config = {'output': (url.netloc + url.path)}
+
+    def run(self, ctx):
+        dest = self.config.get('output')
+
+        if ctx.was_baked:
+            to_upload = list(self.getBakedFiles(ctx))
+            to_delete = list(self.getDeletedFiles(ctx))
+            if to_upload or to_delete:
+                logger.info("Copying new/changed files...")
+                for path in to_upload:
+                    rel_path = os.path.relpath(path, ctx.bake_out_dir)
+                    dest_path = os.path.join(dest, rel_path)
+                    dest_dir = os.path.dirname(dest_path)
+                    os.makedirs(dest_dir, exist_ok=True)
+                    try:
+                        dest_mtime = os.path.getmtime(dest_path)
+                    except OSError:
+                        dest_mtime = 0
+                    if os.path.getmtime(path) >= dest_mtime:
+                        logger.info(rel_path)
+                        if not ctx.preview:
+                            shutil.copyfile(path, dest_path)
+
+                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:
+                            os.remove(path)
+                        except OSError:
+                            pass
+            else:
+                logger.info("Nothing to copy to the output folder.")
+
--- a/piecrust/publishing/publisher.py	Tue Jun 20 21:10:39 2017 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,127 +0,0 @@
-import os.path
-import time
-import logging
-from piecrust.chefutil import format_timed
-from piecrust.publishing.base import PublishingContext
-
-
-logger = logging.getLogger(__name__)
-
-
-class InvalidPublishTargetError(Exception):
-    pass
-
-
-class PublishingError(Exception):
-    pass
-
-
-class Publisher(object):
-    def __init__(self, app):
-        self.app = app
-
-    def run(self, target,
-            force=False, preview=False, extra_args=None, log_file=None,
-            applied_config_variant=None, applied_config_values=None):
-        start_time = time.perf_counter()
-
-        # Get publisher for this target.
-        pub = self.app.getPublisher(target)
-        if pub is None:
-            raise InvalidPublishTargetError(
-                "No such publish target: %s" % target)
-
-        # Will we need to bake first?
-        bake_first = True
-        if not pub.has_url_config:
-            bake_first = pub.getConfigValue('bake', True)
-
-        # Setup logging stuff.
-        hdlr = None
-        root_logger = logging.getLogger()
-        if log_file and not preview:
-            logger.debug("Adding file handler for: %s" % log_file)
-            hdlr = logging.FileHandler(log_file, mode='w', encoding='utf8')
-            root_logger.addHandler(hdlr)
-        if not preview:
-            logger.info("Deploying to %s" % target)
-        else:
-            logger.info("Previewing deployment to %s" % target)
-
-        # Bake first is necessary.
-        rec1 = None
-        rec2 = None
-        was_baked = False
-        bake_out_dir = os.path.join(self.app.root_dir, '_pub', target)
-        if bake_first:
-            if not preview:
-                bake_start_time = time.perf_counter()
-                logger.debug("Baking first to: %s" % bake_out_dir)
-
-                from piecrust.baking.baker import Baker
-                baker = Baker(
-                    self.app, bake_out_dir,
-                    applied_config_variant=applied_config_variant,
-                    applied_config_values=applied_config_values)
-                rec1 = baker.bake()
-
-                from piecrust.processing.pipeline import ProcessorPipeline
-                proc = ProcessorPipeline(
-                    self.app, bake_out_dir,
-                    applied_config_variant=applied_config_variant,
-                    applied_config_values=applied_config_values)
-                rec2 = proc.run()
-
-                was_baked = True
-
-                if not rec1.success or not rec2.success:
-                    raise Exception(
-                        "Error during baking, aborting publishing.")
-                logger.info(format_timed(bake_start_time, "Baked website."))
-            else:
-                logger.info("Would bake to: %s" % bake_out_dir)
-
-        # Publish!
-        logger.debug(
-            "Running publish target '%s' with publisher: %s" %
-            (target, pub.PUBLISHER_NAME))
-        pub_start_time = time.perf_counter()
-
-        ctx = PublishingContext()
-        ctx.bake_out_dir = bake_out_dir
-        ctx.bake_record = rec1
-        ctx.processing_record = rec2
-        ctx.was_baked = was_baked
-        ctx.preview = preview
-        ctx.args = extra_args
-        try:
-            pub.run(ctx)
-        except Exception as ex:
-            raise PublishingError(
-                "Error publishing to target: %s" % target) from ex
-        finally:
-            if hdlr:
-                root_logger.removeHandler(hdlr)
-                hdlr.close()
-
-        logger.info(format_timed(
-            pub_start_time, "Ran publisher %s" % pub.PUBLISHER_NAME))
-
-        logger.info(format_timed(start_time, 'Deployed to %s' % target))
-
-
-def find_publisher_class(app, name, is_scheme=False):
-    attr_name = 'PUBLISHER_SCHEME' if is_scheme else 'PUBLISHER_NAME'
-    for pub_cls in app.plugin_loader.getPublishers():
-        pub_sch = getattr(pub_cls, attr_name, None)
-        if pub_sch == name:
-            return pub_cls
-    return None
-
-
-def find_publisher_name(app, scheme):
-    pub_cls = find_publisher_class(app, scheme, True)
-    if pub_cls:
-        return pub_cls.PUBLISHER_NAME
-    return None
-
--- a/piecrust/publishing/rsync.py	Tue Jun 20 21:10:39 2017 -0700
+++ b/piecrust/publishing/rsync.py	Tue Jun 20 21:12:35 2017 -0700
@@ -1,21 +1,22 @@
-from piecrust.publishing.base import ShellCommandPublisherBase
+from piecrust.publishing.shell import ShellCommandPublisherBase
 
 
 class RsyncPublisher(ShellCommandPublisherBase):
     PUBLISHER_NAME = 'rsync'
     PUBLISHER_SCHEME = 'rsync'
 
+    def parseUrlTarget(self, url):
+        self.config = {
+            'destination': (url.netloc + url.path)
+        }
+
     def _getCommandArgs(self, ctx):
-        if self.has_url_config:
-            orig = ctx.bake_out_dir
-            dest = self.config.netloc + self.config.path
-        else:
-            orig = self.getConfigValue('source', ctx.bake_out_dir)
-            dest = self.getConfigValue('destination')
+        orig = self.config.get('source', ctx.bake_out_dir)
+        dest = self.config.get('destination')
+        if not dest:
+            raise Exception("No destination specified.")
 
-        rsync_options = None
-        if not self.has_url_config:
-            rsync_options = self.getConfigValue('options')
+        rsync_options = self.config.get('options')
         if rsync_options is None:
             rsync_options = ['-avc', '--delete']
 
--- a/piecrust/publishing/sftp.py	Tue Jun 20 21:10:39 2017 -0700
+++ b/piecrust/publishing/sftp.py	Tue Jun 20 21:12:35 2017 -0700
@@ -1,6 +1,5 @@
 import os
 import os.path
-import socket
 import urllib.parse
 import getpass
 import logging
@@ -17,20 +16,21 @@
 
     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."))
+            '--force',
+            action='store_true',
+            help=("Upload the entire bake directory instead of only "
+                  "the files changed by the last bake."))
+
+    def parseUrlTarget(self, url):
+        self.config = {'host': str(url)}
 
     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)
+        host = self.config.get('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
@@ -39,16 +39,9 @@
             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')
+        username = self.config.get('username', remote.username)
+        path = self.config.get('path', path)
+        pkey_path = self.config.get('key')
 
         password = None
         if username and not ctx.preview:
@@ -65,10 +58,10 @@
         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)
+            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())
@@ -120,9 +113,11 @@
                         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.")
+                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):
@@ -148,7 +143,7 @@
             cur = os.path.join(cur, b)
             if cur not in known_dirs:
                 try:
-                    info = client.stat(cur)
+                    client.stat(cur)
                 except FileNotFoundError:
                     logger.debug("Creating remote dir: %s" % cur)
                     client.mkdir(cur)
--- a/piecrust/publishing/shell.py	Tue Jun 20 21:10:39 2017 -0700
+++ b/piecrust/publishing/shell.py	Tue Jun 20 21:12:35 2017 -0700
@@ -1,5 +1,71 @@
+import os.path
 import shlex
-from piecrust.publishing.base import ShellCommandPublisherBase
+import logging
+import threading
+import subprocess
+from piecrust.publishing.base import Publisher
+
+
+logger = logging.getLogger(__name__)
+
+
+class ShellCommandPublisherBase(Publisher):
+    def __init__(self, app, target, config):
+        super(ShellCommandPublisherBase, self).__init__(app, target, config)
+        self.expand_user_args = True
+
+    def run(self, ctx):
+        args = self._getCommandArgs(ctx)
+        if self.expand_user_args:
+            args = [os.path.expanduser(i) for i in args]
+
+        if ctx.preview:
+            preview_args = ' '.join([shlex.quote(i) for i in args])
+            logger.info(
+                "Would run shell command: %s" % preview_args)
+            return True
+
+        logger.debug(
+            "Running shell command: %s" % args)
+
+        proc = subprocess.Popen(
+            args, cwd=self.app.root_dir, bufsize=0,
+            stdout=subprocess.PIPE)
+
+        logger.debug("Running publishing monitor for PID %d" % proc.pid)
+        thread = _PublishThread(proc)
+        thread.start()
+        proc.wait()
+        thread.join()
+
+        if proc.returncode != 0:
+            logger.error(
+                "Publish process returned code %d" % proc.returncode)
+        else:
+            logger.debug("Publish process returned successfully.")
+
+        return proc.returncode == 0
+
+    def _getCommandArgs(self, ctx):
+        raise NotImplementedError()
+
+
+class _PublishThread(threading.Thread):
+    def __init__(self, proc):
+        super(_PublishThread, self).__init__(
+            name='publish_monitor', daemon=True)
+        self.proc = proc
+        self.root_logger = logging.getLogger()
+
+    def run(self):
+        for line in iter(self.proc.stdout.readline, b''):
+            line_str = line.decode('utf8')
+            logger.info(line_str.rstrip('\r\n'))
+            for h in self.root_logger.handlers:
+                h.flush()
+
+        self.proc.communicate()
+        logger.debug("Publish monitor exiting.")
 
 
 class ShellCommandPublisher(ShellCommandPublisherBase):
--- a/piecrust/sources/mixins.py	Tue Jun 20 21:10:39 2017 -0700
+++ b/piecrust/sources/mixins.py	Tue Jun 20 21:12:35 2017 -0700
@@ -21,8 +21,8 @@
         spec_no_ext, _ = os.path.splitext(item.spec)
         assets_dir = spec_no_ext + assets_suffix
         try:
-            asset_files = osutil.listdir(assets_dir)
-        except OSError:
+            asset_files = list(osutil.listdir(assets_dir))
+        except (OSError, FileNotFoundError):
             return None
 
         assets = []