From 0a361bc51f71fbcec570933677c2f73894fdf4a0 Mon Sep 17 00:00:00 2001 From: Simon Conseil Date: Tue, 3 Dec 2013 23:08:30 +0100 Subject: [PATCH 1/5] Use multiprocessing. --- sigal/__init__.py | 5 ++- sigal/gallery.py | 112 ++++++++++++++++++++++++++++++---------------- 2 files changed, 77 insertions(+), 40 deletions(-) diff --git a/sigal/__init__.py b/sigal/__init__.py index eb60a00..29a6ed5 100644 --- a/sigal/__init__.py +++ b/sigal/__init__.py @@ -86,8 +86,9 @@ def init(): "the current working directory)") @arg('-t', '--theme', help="Specify a theme directory, or a theme name for " "the themes included with Sigal") +@arg('-n', '--ncpu', help="Number of cpu for parallel execution (default: 1)") def build(source, destination, debug=False, verbose=False, force=False, - config=None, theme=None): + config=None, theme=None, ncpu=None): """Run sigal to process a directory. """ level = ((debug and logging.DEBUG) or (verbose and logging.INFO) @@ -119,7 +120,7 @@ def build(source, destination, debug=False, verbose=False, force=False, sys.exit(1) locale.setlocale(locale.LC_ALL, settings['locale']) - gal = Gallery(settings, force=force, theme=theme) + gal = Gallery(settings, force=force, theme=theme, ncpu=ncpu) gal.build() diff --git a/sigal/gallery.py b/sigal/gallery.py index f6506cc..5a7a942 100644 --- a/sigal/gallery.py +++ b/sigal/gallery.py @@ -21,7 +21,7 @@ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS # IN THE SOFTWARE. -from __future__ import absolute_import +from __future__ import absolute_import, print_function import codecs import locale @@ -33,6 +33,7 @@ import sys import zipfile from clint.textui import progress, colored +from multiprocessing import Pool, cpu_count, current_process from os.path import join from PIL import Image as PILImage @@ -172,14 +173,23 @@ class PathsDb(object): class Gallery(object): "Prepare images" - def __init__(self, settings, force=False, theme=None): + def __init__(self, settings, force=False, theme=None, ncpu=None): self.settings = settings self.force = force + self.theme = theme self.logger = logging.getLogger(__name__) - if self.settings['write_html']: - self.writer = Writer(settings, self.settings['destination'], - theme=theme) + if ncpu is not None: + try: + ncpu = int(ncpu) + except ValueError: + self.logger.error('ncpu should be an integer value') + ncpu = cpu_count() + except NotImplementedError: + ncpu = 1 + + self.logger.info("Using %s cores", ncpu) + self.pool = Pool(processes=ncpu) paths = PathsDb(self.settings['source'], self.settings['img_ext_list'], self.settings['vid_ext_list']) @@ -196,6 +206,8 @@ class Gallery(object): # loop on directories in reversed order, to process subdirectories # before their parent + media_list = [] + for path in reversed(self.db['paths_list']): source = self.settings['source'] media_files = [os.path.normpath(join(source, path, f)) @@ -207,10 +219,23 @@ class Gallery(object): check_or_create_dir(outpath) if len(media_files) != 0: - self.process_dir(media_files, outpath, path, - label_width=label_width) + for files in self.process_dir(media_files, outpath, path, + label_width=label_width): + media_list.append(files) - if self.settings['write_html']: + try: + self.pool.map(worker, media_list) + self.pool.close() + self.pool.join() + except KeyboardInterrupt: + self.pool.terminate() + sys.exit('Interrupted') + + if self.settings['write_html']: + self.writer = Writer(self.settings, self.settings['destination'], + theme=self.theme) + + for path in reversed(self.db['paths_list']): self.writer.write(self.db, path) def process_dir(self, media_files, outpath, dirname, label_width=20): @@ -234,35 +259,35 @@ class Gallery(object): colored.green(dirname), len(media_files)) self.logger.info("") - try: - # loop on images - if self.settings['zip_gallery']: - self._zip_files(outpath, media_files) + # loop on images + if self.settings['zip_gallery']: + self._zip_files(outpath, media_files) + + for f in media_iterator: + filename = os.path.split(f)[1] + base, ext = os.path.splitext(filename) + + if ext in self.settings['img_ext_list']: + outname = join(outpath, filename) + elif ext in self.settings['vid_ext_list']: + outname = join(outpath, base + '.webm') + else: + raise FileExtensionError + + if os.path.isfile(outname) and not self.force: + self.logger.info("%s exists - skipping", filename) + else: + if self.settings['keep_orig']: + copy(f, join(outpath, self.settings['orig_dir'], filename), + symlink=self.settings['orig_link']) - for f in media_iterator: - filename = os.path.split(f)[1] - base, ext = os.path.splitext(filename) if ext in self.settings['img_ext_list']: - outname = join(outpath, filename) + yield 'image', f, outpath, self.settings elif ext in self.settings['vid_ext_list']: - outname = join(outpath, base + '.webm') + yield 'video', f, outpath, self.settings else: raise FileExtensionError - if os.path.isfile(outname) and not self.force: - self.logger.info("%s exists - skipping", filename) - else: - self.logger.info(filename) - if ext in self.settings['img_ext_list']: - process_image(f, outpath, self.settings) - elif ext in self.settings['vid_ext_list']: - process_video(f, outpath, self.settings) - else: - raise FileExtensionError - - except KeyboardInterrupt: - sys.exit('Interrupted') - def _zip_files(self, outpath, filepaths): archive_name = join(outpath, str(self.settings['zip_gallery'])) archive = zipfile.ZipFile(archive_name, 'w') @@ -274,6 +299,16 @@ class Gallery(object): archive.close() +def worker(args): + try: + if args[0] == 'image': + return process_image(*args[1:]) + elif args[0] == 'video': + return process_video(*args[1:]) + except KeyboardInterrupt: + return 'KeyboardException' + + def process_image(filepath, outpath, settings): """Process one image: resize, create thumbnail.""" @@ -281,6 +316,12 @@ def process_image(filepath, outpath, settings): outname = join(outpath, filename) ext = os.path.splitext(filename) + logger = logging.getLogger(__name__) + logger.info(filename) + + if logger.getEffectiveLevel() > 20: + print('.', end='') + if ext in ['.jpg', '.jpeg', '.JPG', '.JPEG']: options = settings['jpg_options'] elif ext == '.png': @@ -288,10 +329,6 @@ def process_image(filepath, outpath, settings): else: options = {} - if settings['keep_orig']: - copy(filepath, join(outpath, settings['orig_dir'], filename), - symlink=settings['orig_link']) - image.generate_image(filepath, outname, settings, options=options) if settings['make_thumbs']: @@ -308,9 +345,8 @@ def process_video(filepath, outpath, settings): base, ext = os.path.splitext(filename) outname = join(outpath, base + '.webm') - if settings['keep_orig']: - copy(filepath, join(outpath, settings['orig_dir'], filename), - symlink=settings['orig_link']) + logger = logging.getLogger(__name__) + logger.info(filename) video.generate_video(filepath, outname, settings['video_size'], settings['webm_options']) From 8843903f4667c77d3604137bae48d2519eb87ae3 Mon Sep 17 00:00:00 2001 From: Simon Conseil Date: Mon, 9 Dec 2013 23:34:02 +0100 Subject: [PATCH 2/5] Move zip_files out of the class. --- sigal/gallery.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/sigal/gallery.py b/sigal/gallery.py index 5a7a942..57684b0 100644 --- a/sigal/gallery.py +++ b/sigal/gallery.py @@ -261,7 +261,7 @@ class Gallery(object): # loop on images if self.settings['zip_gallery']: - self._zip_files(outpath, media_files) + zip_files(join(outpath, self.settings['zip_gallery']), media_files) for f in media_iterator: filename = os.path.split(f)[1] @@ -288,17 +288,6 @@ class Gallery(object): else: raise FileExtensionError - def _zip_files(self, outpath, filepaths): - archive_name = join(outpath, str(self.settings['zip_gallery'])) - archive = zipfile.ZipFile(archive_name, 'w') - - for p in filepaths: - filename = os.path.split(p)[1] - archive.write(p, filename) - - archive.close() - - def worker(args): try: if args[0] == 'image': @@ -405,3 +394,13 @@ def check_or_create_dir(path): if not os.path.isdir(path): os.makedirs(path) + + +def zip_files(archive_path, filepaths): + archive = zipfile.ZipFile(archive_path, 'w') + + for p in filepaths: + filename = os.path.split(p)[1] + archive.write(p, filename) + + archive.close() From c62ec6a8b95f1cea14e134ea41c9a0762a2c3e5b Mon Sep 17 00:00:00 2001 From: Simon Conseil Date: Tue, 10 Dec 2013 00:08:14 +0100 Subject: [PATCH 3/5] Remove the progress bar. --- sigal/gallery.py | 37 ++++++++++++------------------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/sigal/gallery.py b/sigal/gallery.py index 57684b0..159bd40 100644 --- a/sigal/gallery.py +++ b/sigal/gallery.py @@ -32,8 +32,8 @@ import shutil import sys import zipfile -from clint.textui import progress, colored -from multiprocessing import Pool, cpu_count, current_process +from clint.textui import colored +from multiprocessing import Pool, cpu_count from os.path import join from PIL import Image as PILImage @@ -43,10 +43,6 @@ from .writer import Writer DESCRIPTION_FILE = "index.md" -# Label with for the progress bar. The max value is 48 character = 80 - 32 for -# the progress bar. -MAX_LABEL_WIDTH = 45 - class FileExtensionError(Exception): """Raised if we made an error when handling file extensions""" @@ -200,10 +196,6 @@ class Gallery(object): check_or_create_dir(self.settings['destination']) - # Compute the label with for the progress bar - label_width = max((len(p) for p in self.db['paths_list'])) + 1 - label_width = min(label_width, MAX_LABEL_WIDTH) - # loop on directories in reversed order, to process subdirectories # before their parent media_list = [] @@ -219,8 +211,7 @@ class Gallery(object): check_or_create_dir(outpath) if len(media_files) != 0: - for files in self.process_dir(media_files, outpath, path, - label_width=label_width): + for files in self.process_dir(media_files, outpath, path): media_list.append(files) try: @@ -231,6 +222,9 @@ class Gallery(object): self.pool.terminate() sys.exit('Interrupted') + if self.logger.getEffectiveLevel() > 20: + print('') + if self.settings['write_html']: self.writer = Writer(self.settings, self.settings['destination'], theme=self.theme) @@ -238,7 +232,7 @@ class Gallery(object): for path in reversed(self.db['paths_list']): self.writer.write(self.db, path) - def process_dir(self, media_files, outpath, dirname, label_width=20): + def process_dir(self, media_files, outpath, dirname): """Process a list of images in a directory.""" # Create thumbnails directory and optionally the one for original img @@ -247,23 +241,14 @@ class Gallery(object): if self.settings['keep_orig']: check_or_create_dir(join(outpath, self.settings['orig_dir'])) - # use progressbar if level is > INFO - if self.logger.getEffectiveLevel() > 20: - label = dirname[:MAX_LABEL_WIDTH - 1] - label = colored.green(label.ljust(label_width)) - media_iterator = progress.bar(media_files, label=label) - else: - media_iterator = iter(media_files) - self.logger.info("") - self.logger.info(":: Processing '%s' [%i img/vid]", - colored.green(dirname), len(media_files)) - self.logger.info("") + self.logger.warn(":: Analyzing '%s' : %i images/videos", + colored.green(dirname), len(media_files)) # loop on images if self.settings['zip_gallery']: zip_files(join(outpath, self.settings['zip_gallery']), media_files) - for f in media_iterator: + for f in media_files: filename = os.path.split(f)[1] base, ext = os.path.splitext(filename) @@ -288,6 +273,7 @@ class Gallery(object): else: raise FileExtensionError + def worker(args): try: if args[0] == 'image': @@ -310,6 +296,7 @@ def process_image(filepath, outpath, settings): if logger.getEffectiveLevel() > 20: print('.', end='') + sys.stdout.flush() if ext in ['.jpg', '.jpeg', '.JPG', '.JPEG']: options = settings['jpg_options'] From fc3082f3ed102f5f11c783743862d78e003fc7e8 Mon Sep 17 00:00:00 2001 From: Simon Conseil Date: Tue, 10 Dec 2013 00:57:44 +0100 Subject: [PATCH 4/5] Add some timing info at the end of the processing. --- sigal/__init__.py | 9 ++++++++- sigal/gallery.py | 43 +++++++++++++++++++++---------------------- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/sigal/__init__.py b/sigal/__init__.py index 29a6ed5..de16d41 100644 --- a/sigal/__init__.py +++ b/sigal/__init__.py @@ -33,10 +33,12 @@ sigal is yet another python script to prepare a static gallery of images: from __future__ import absolute_import, print_function import io +import locale import logging import os import sys -import locale +import time + from argh import ArghParser, arg from logging import Formatter @@ -96,6 +98,7 @@ def build(source, destination, debug=False, verbose=False, force=False, init_logging(level=level) logger = logging.getLogger(__name__) + start_time = time.time() settings_file = config or _DEFAULT_CONFIG_FILE if not os.path.isfile(settings_file): logger.error("Settings file not found: %s", settings_file) @@ -123,6 +126,10 @@ def build(source, destination, debug=False, verbose=False, force=False, gal = Gallery(settings, force=force, theme=theme, ncpu=ncpu) gal.build() + print(('Done: Processed {image} images ({image_skipped} skipped) and ' + '{video} videos ({video_skipped} skipped) in {duration:.2f} ' + 'seconds.').format(duration=time.time() - start_time, **gal.stats)) + @arg('path', nargs='?', default='_build', help='Directory to serve (default: _build/)') diff --git a/sigal/gallery.py b/sigal/gallery.py index 159bd40..29cbac9 100644 --- a/sigal/gallery.py +++ b/sigal/gallery.py @@ -34,7 +34,7 @@ import zipfile from clint.textui import colored from multiprocessing import Pool, cpu_count -from os.path import join +from os.path import join, normpath from PIL import Image as PILImage from . import compat, image, video @@ -84,7 +84,7 @@ class PathsDb(object): """Return the list of all sub-directories of path.""" for name in self.db[path].get('subdir', []): - subdir = os.path.normpath(os.path.join(path, name)) + subdir = normpath(join(path, name)) yield subdir for subname in self.get_subdirs(subdir): yield subname @@ -140,7 +140,7 @@ class PathsDb(object): self.db['skipped_dir'].append(path) self.db['paths_list'].remove(path) del self.db[path] - parent = os.path.normpath(join(path, '..')) + parent = normpath(join(path, '..')) child = os.path.relpath(path, parent) self.db[parent]['subdir'].remove(child) @@ -174,6 +174,8 @@ class Gallery(object): self.force = force self.theme = theme self.logger = logging.getLogger(__name__) + self.stats = {'image': 0, 'image_skipped': 0, + 'video': 0, 'video_skipped': 0} if ncpu is not None: try: @@ -202,12 +204,11 @@ class Gallery(object): for path in reversed(self.db['paths_list']): source = self.settings['source'] - media_files = [os.path.normpath(join(source, path, f)) + media_files = [normpath(join(source, path, f)) for f in self.db[path]['medias']] # output dir for the current path - outpath = os.path.normpath(join(self.settings['destination'], - path)) + outpath = normpath(join(self.settings['destination'], path)) check_or_create_dir(outpath) if len(media_files) != 0: @@ -254,28 +255,34 @@ class Gallery(object): if ext in self.settings['img_ext_list']: outname = join(outpath, filename) + filetype = 'image' elif ext in self.settings['vid_ext_list']: outname = join(outpath, base + '.webm') + filetype = 'video' else: raise FileExtensionError if os.path.isfile(outname) and not self.force: self.logger.info("%s exists - skipping", filename) + self.stats[filetype + '_skipped'] += 1 else: if self.settings['keep_orig']: copy(f, join(outpath, self.settings['orig_dir'], filename), symlink=self.settings['orig_link']) - if ext in self.settings['img_ext_list']: - yield 'image', f, outpath, self.settings - elif ext in self.settings['vid_ext_list']: - yield 'video', f, outpath, self.settings - else: - raise FileExtensionError + self.stats[filetype] += 1 + yield filetype, f, outpath, self.settings def worker(args): try: + logger = logging.getLogger(__name__) + logger.info('Processing %s', args[1]) + + if logger.getEffectiveLevel() > 20: + print('.', end='') + sys.stdout.flush() + if args[0] == 'image': return process_image(*args[1:]) elif args[0] == 'video': @@ -291,13 +298,6 @@ def process_image(filepath, outpath, settings): outname = join(outpath, filename) ext = os.path.splitext(filename) - logger = logging.getLogger(__name__) - logger.info(filename) - - if logger.getEffectiveLevel() > 20: - print('.', end='') - sys.stdout.flush() - if ext in ['.jpg', '.jpeg', '.JPG', '.JPEG']: options = settings['jpg_options'] elif ext == '.png': @@ -321,9 +321,6 @@ def process_video(filepath, outpath, settings): base, ext = os.path.splitext(filename) outname = join(outpath, base + '.webm') - logger = logging.getLogger(__name__) - logger.info(filename) - video.generate_video(filepath, outname, settings['video_size'], settings['webm_options']) @@ -337,6 +334,8 @@ def process_video(filepath, outpath, settings): def copy(src, dst, symlink=False): """Copy or symlink the file.""" func = os.symlink if symlink else shutil.copy2 + if symlink and os.path.lexists(dst): + os.remove(dst) func(src, dst) From b7d5244a21465175d1fea7cb11a6f5a6ababc9b2 Mon Sep 17 00:00:00 2001 From: Simon Conseil Date: Mon, 16 Dec 2013 22:33:25 +0100 Subject: [PATCH 5/5] Refactor to handle KeyboardInterrupt and avoid using the pool for one process. --- sigal/__init__.py | 2 +- sigal/gallery.py | 86 ++++++++++++++++++++++++++++------------------- 2 files changed, 52 insertions(+), 36 deletions(-) diff --git a/sigal/__init__.py b/sigal/__init__.py index de16d41..e927a6b 100644 --- a/sigal/__init__.py +++ b/sigal/__init__.py @@ -126,7 +126,7 @@ def build(source, destination, debug=False, verbose=False, force=False, gal = Gallery(settings, force=force, theme=theme, ncpu=ncpu) gal.build() - print(('Done: Processed {image} images ({image_skipped} skipped) and ' + print(('Done.\nProcessed {image} images ({image_skipped} skipped) and ' '{video} videos ({video_skipped} skipped) in {duration:.2f} ' 'seconds.').format(duration=time.time() - start_time, **gal.stats)) diff --git a/sigal/gallery.py b/sigal/gallery.py index 29cbac9..68cf1c6 100644 --- a/sigal/gallery.py +++ b/sigal/gallery.py @@ -186,8 +186,11 @@ class Gallery(object): except NotImplementedError: ncpu = 1 + self.pool = Pool(processes=ncpu) + else: + self.pool = None + self.logger.info("Using %s cores", ncpu) - self.pool = Pool(processes=ncpu) paths = PathsDb(self.settings['source'], self.settings['img_ext_list'], self.settings['vid_ext_list']) @@ -200,31 +203,33 @@ class Gallery(object): # loop on directories in reversed order, to process subdirectories # before their parent - media_list = [] - for path in reversed(self.db['paths_list']): - source = self.settings['source'] - media_files = [normpath(join(source, path, f)) - for f in self.db[path]['medias']] + if self.pool: + media_list = [] - # output dir for the current path - outpath = normpath(join(self.settings['destination'], path)) - check_or_create_dir(outpath) + for path in reversed(self.db['paths_list']): + if len(self.db[path]['medias']) != 0: + for files in self.process_dir(path): + media_list.append(files) - if len(media_files) != 0: - for files in self.process_dir(media_files, outpath, path): - media_list.append(files) + try: + # map_async is needed to handle KeyboardInterrupt correctly + self.pool.map_async(worker2, media_list).get(9999) + self.pool.close() + self.pool.join() + except KeyboardInterrupt: + self.pool.terminate() + sys.exit('Interrupted') + else: + try: + for path in reversed(self.db['paths_list']): + if len(self.db[path]['medias']) != 0: + for files in self.process_dir(path): + worker(files) + except KeyboardInterrupt: + sys.exit('Interrupted') - try: - self.pool.map(worker, media_list) - self.pool.close() - self.pool.join() - except KeyboardInterrupt: - self.pool.terminate() - sys.exit('Interrupted') - - if self.logger.getEffectiveLevel() > 20: - print('') + print('') if self.settings['write_html']: self.writer = Writer(self.settings, self.settings['destination'], @@ -233,9 +238,16 @@ class Gallery(object): for path in reversed(self.db['paths_list']): self.writer.write(self.db, path) - def process_dir(self, media_files, outpath, dirname): + def process_dir(self, path): """Process a list of images in a directory.""" + media_files = [normpath(join(self.settings['source'], path, f)) + for f in self.db[path]['medias']] + + # output dir for the current path + outpath = normpath(join(self.settings['destination'], path)) + check_or_create_dir(outpath) + # Create thumbnails directory and optionally the one for original img check_or_create_dir(join(outpath, self.settings['thumb_dir'])) @@ -243,7 +255,7 @@ class Gallery(object): check_or_create_dir(join(outpath, self.settings['orig_dir'])) self.logger.warn(":: Analyzing '%s' : %i images/videos", - colored.green(dirname), len(media_files)) + colored.green(path), len(media_files)) # loop on images if self.settings['zip_gallery']: @@ -275,18 +287,22 @@ class Gallery(object): def worker(args): + logger = logging.getLogger(__name__) + logger.info('Processing %s', args[1]) + + if logger.getEffectiveLevel() > 20: + print('.', end='') + sys.stdout.flush() + + if args[0] == 'image': + return process_image(*args[1:]) + elif args[0] == 'video': + return process_video(*args[1:]) + + +def worker2(args): try: - logger = logging.getLogger(__name__) - logger.info('Processing %s', args[1]) - - if logger.getEffectiveLevel() > 20: - print('.', end='') - sys.stdout.flush() - - if args[0] == 'image': - return process_image(*args[1:]) - elif args[0] == 'video': - return process_video(*args[1:]) + worker(args) except KeyboardInterrupt: return 'KeyboardException'