sigal 2.3__py3-none-any.whl → 2.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. sigal/__init__.py +2 -285
  2. sigal/__main__.py +312 -0
  3. sigal/gallery.py +188 -158
  4. sigal/image.py +113 -115
  5. sigal/log.py +11 -11
  6. sigal/plugins/adjust.py +4 -4
  7. sigal/plugins/compress_assets.py +26 -25
  8. sigal/plugins/copyright.py +8 -8
  9. sigal/plugins/encrypt/encrypt.py +7 -7
  10. sigal/plugins/encrypt/endec.py +2 -2
  11. sigal/plugins/extended_caching.py +26 -22
  12. sigal/plugins/feeds.py +19 -21
  13. sigal/plugins/media_page.py +1 -1
  14. sigal/plugins/nomedia.py +1 -1
  15. sigal/plugins/nonmedia_files.py +59 -93
  16. sigal/plugins/titleregexp.py +98 -0
  17. sigal/plugins/watermark.py +13 -13
  18. sigal/plugins/zip_gallery.py +17 -8
  19. sigal/settings.py +92 -78
  20. sigal/signals.py +10 -10
  21. sigal/templates/sigal.conf.py +18 -14
  22. sigal/themes/default/templates/decrypt.html +1 -0
  23. sigal/themes/default/templates/description.html +29 -0
  24. sigal/themes/default/templates/footer.html +3 -0
  25. sigal/themes/galleria/templates/album_items.html +4 -23
  26. sigal/themes/photoswipe/static/photoswipe-dynamic-caption-plugin.esm.js +414 -0
  27. sigal/themes/photoswipe/static/photoswipe-dynamic-caption-plugin.esm.min.js +5 -0
  28. sigal/themes/photoswipe/static/photoswipe-fullscreen.esm.js +129 -0
  29. sigal/themes/photoswipe/static/photoswipe-fullscreen.esm.min.js +8 -0
  30. sigal/themes/photoswipe/static/photoswipe-lightbox.esm.js +1960 -0
  31. sigal/themes/photoswipe/static/photoswipe-lightbox.esm.js.map +1 -0
  32. sigal/themes/photoswipe/static/photoswipe-lightbox.esm.min.js +5 -0
  33. sigal/themes/photoswipe/static/photoswipe-video-plugin.esm.js +257 -0
  34. sigal/themes/photoswipe/static/photoswipe-video-plugin.esm.min.js +1 -0
  35. sigal/themes/photoswipe/static/photoswipe.css +385 -140
  36. sigal/themes/photoswipe/static/photoswipe.esm.js +7081 -0
  37. sigal/themes/photoswipe/static/photoswipe.esm.js.map +1 -0
  38. sigal/themes/photoswipe/static/photoswipe.esm.min.js +5 -0
  39. sigal/themes/photoswipe/static/styles.css +53 -0
  40. sigal/themes/photoswipe/templates/album.html +69 -74
  41. sigal/utils.py +80 -12
  42. sigal/version.py +20 -4
  43. sigal/video.py +43 -24
  44. sigal/writer.py +26 -8
  45. {sigal-2.3.dist-info → sigal-2.5.dist-info}/LICENSE +1 -1
  46. {sigal-2.3.dist-info → sigal-2.5.dist-info}/METADATA +23 -30
  47. {sigal-2.3.dist-info → sigal-2.5.dist-info}/RECORD +50 -50
  48. {sigal-2.3.dist-info → sigal-2.5.dist-info}/WHEEL +1 -1
  49. sigal-2.5.dist-info/entry_points.txt +2 -0
  50. sigal/plugins/upload_s3.py +0 -106
  51. sigal/themes/photoswipe/static/app.js +0 -214
  52. sigal/themes/photoswipe/static/default-skin/default-skin.css +0 -485
  53. sigal/themes/photoswipe/static/default-skin/default-skin.css.map +0 -10
  54. sigal/themes/photoswipe/static/default-skin/default-skin.png +0 -0
  55. sigal/themes/photoswipe/static/default-skin/default-skin.svg +0 -36
  56. sigal/themes/photoswipe/static/default-skin/preloader.gif +0 -0
  57. sigal/themes/photoswipe/static/echo/blank.gif +0 -0
  58. sigal/themes/photoswipe/static/echo/echo.js +0 -135
  59. sigal/themes/photoswipe/static/echo/echo.min.js +0 -2
  60. sigal/themes/photoswipe/static/photoswipe-ui-default.js +0 -871
  61. sigal/themes/photoswipe/static/photoswipe-ui-default.min.js +0 -1
  62. sigal/themes/photoswipe/static/photoswipe.css.map +0 -10
  63. sigal/themes/photoswipe/static/photoswipe.js +0 -3592
  64. sigal/themes/photoswipe/static/photoswipe.min.js +0 -1
  65. sigal-2.3.dist-info/entry_points.txt +0 -2
  66. {sigal-2.3.dist-info → sigal-2.5.dist-info}/top_level.txt +0 -0
sigal/gallery.py CHANGED
@@ -1,4 +1,4 @@
1
- # Copyright (c) 2009-2020 - Simon Conseil
1
+ # Copyright (c) 2009-2023 - Simon Conseil
2
2
  # Copyright (c) 2013 - Christophe-Marie Duquesne
3
3
  # Copyright (c) 2014 - Jonas Kaufmann
4
4
  # Copyright (c) 2015 - François D.
@@ -25,6 +25,7 @@
25
25
  # IN THE SOFTWARE.
26
26
 
27
27
  import fnmatch
28
+ import io
28
29
  import logging
29
30
  import multiprocessing
30
31
  import os
@@ -44,7 +45,13 @@ from natsort import natsort_keygen, ns
44
45
  from PIL import Image as PILImage
45
46
 
46
47
  from . import image, signals, video
47
- from .image import get_exif_tags, get_image_metadata, get_size, process_image
48
+ from .image import (
49
+ EXIF_EXTENSIONS,
50
+ get_exif_tags,
51
+ get_image_metadata,
52
+ get_size,
53
+ process_image,
54
+ )
48
55
  from .settings import Status, get_thumb
49
56
  from .utils import (
50
57
  Devnull,
@@ -54,6 +61,7 @@ from .utils import (
54
61
  get_mod_date,
55
62
  is_valid_html5_video,
56
63
  read_markdown,
64
+ should_reprocess_album,
57
65
  url_from_path,
58
66
  )
59
67
  from .video import process_video
@@ -73,7 +81,7 @@ class Media:
73
81
 
74
82
  """
75
83
 
76
- type = ''
84
+ type = ""
77
85
  """Type of media, e.g. ``"image"`` or ``"video"``."""
78
86
 
79
87
  def __init__(self, filename, path, settings):
@@ -91,7 +99,7 @@ class Media:
91
99
  self.src_ext = os.path.splitext(filename)[1].lower()
92
100
  """Input extension."""
93
101
 
94
- self.src_path = join(settings['source'], path, self.src_filename)
102
+ self.src_path = join(settings["source"], path, self.src_filename)
95
103
 
96
104
  self.thumb_name = get_thumb(self.settings, self.dst_filename)
97
105
 
@@ -108,7 +116,7 @@ class Media:
108
116
  def __getstate__(self):
109
117
  state = self.__dict__.copy()
110
118
  # remove un-pickable objects
111
- state['logger'] = None
119
+ state["logger"] = None
112
120
  return state
113
121
 
114
122
  def __setstate__(self, state):
@@ -118,11 +126,11 @@ class Media:
118
126
 
119
127
  @property
120
128
  def dst_path(self):
121
- return join(self.settings['destination'], self.path, self.dst_filename)
129
+ return join(self.settings["destination"], self.path, self.dst_filename)
122
130
 
123
131
  @property
124
132
  def thumb_path(self):
125
- return join(self.settings['destination'], self.path, self.thumb_name)
133
+ return join(self.settings["destination"], self.path, self.thumb_name)
126
134
 
127
135
  @property
128
136
  def url(self):
@@ -134,22 +142,22 @@ class Media:
134
142
  """Path to the original image, if ``keep_orig`` is set (relative to the
135
143
  album directory). Copy the file if needed.
136
144
  """
137
- if self.settings['keep_orig']:
145
+ if self.settings["keep_orig"]:
138
146
  s = self.settings
139
- if s['use_orig']:
147
+ if s["use_orig"]:
140
148
  # The image *is* the original, just use it
141
149
  return self.src_filename
142
- orig_path = join(s['destination'], self.path, s['orig_dir'])
150
+ orig_path = join(s["destination"], self.path, s["orig_dir"])
143
151
  check_or_create_dir(orig_path)
144
152
  big_path = join(orig_path, self.src_filename)
145
153
  if not isfile(big_path):
146
154
  copy(
147
155
  self.src_path,
148
156
  big_path,
149
- symlink=s['orig_link'],
150
- rellink=self.settings['rel_link'],
157
+ symlink=s["orig_link"],
158
+ rellink=self.settings["rel_link"],
151
159
  )
152
- return join(s['orig_dir'], self.src_filename)
160
+ return join(s["orig_dir"], self.src_filename)
153
161
 
154
162
  @property
155
163
  def big_url(self):
@@ -162,44 +170,47 @@ class Media:
162
170
  """Path to the thumbnail image (relative to the album directory)."""
163
171
 
164
172
  if not isfile(self.thumb_path):
165
- self.logger.debug('Generating thumbnail for %r', self)
173
+ self.logger.debug("Generating thumbnail for %r", self)
166
174
  path = self.dst_path if os.path.exists(self.dst_path) else self.src_path
167
175
  try:
168
176
  # if thumbnail is missing (if settings['make_thumbs'] is False)
169
177
  s = self.settings
170
- if self.type == 'image':
178
+ if self.type == "image":
171
179
  image.generate_thumbnail(
172
- path, self.thumb_path, s['thumb_size'], fit=s['thumb_fit']
180
+ path, self.thumb_path, s["thumb_size"], fit=s["thumb_fit"]
173
181
  )
174
- elif self.type == 'video':
182
+ elif self.type == "video":
175
183
  video.generate_thumbnail(
176
184
  path,
177
185
  self.thumb_path,
178
- s['thumb_size'],
179
- s['thumb_video_delay'],
180
- fit=s['thumb_fit'],
181
- converter=s['video_converter'],
186
+ s["thumb_size"],
187
+ s["thumb_video_delay"],
188
+ fit=s["thumb_fit"],
189
+ converter=s["video_converter"],
190
+ black_retries=s["thumb_video_black_retries"],
191
+ black_offset=s["thumb_video_black_retry_offset"],
192
+ black_max_colors=s["thumb_video_black_max_colors"],
182
193
  )
183
194
  except Exception as e:
184
- self.logger.error('Failed to generate thumbnail: %s', e)
195
+ self.logger.error("Failed to generate thumbnail: %s", e)
185
196
  return
186
197
  return url_from_path(self.thumb_name)
187
198
 
188
199
  @cached_property
189
200
  def description(self):
190
201
  """Description extracted from the Markdown <imagename>.md file."""
191
- return self.markdown_metadata.get('description', '')
202
+ return self.markdown_metadata.get("description", "")
192
203
 
193
204
  @cached_property
194
205
  def title(self):
195
206
  """Title extracted from the metadata, or defaults to the filename."""
196
- title = self.markdown_metadata.get('title', '')
207
+ title = self.markdown_metadata.get("title", "")
197
208
  return title if title else self.basename
198
209
 
199
210
  @cached_property
200
211
  def meta(self):
201
212
  """Other metadata extracted from the Markdown <imagename>.md file."""
202
- return self.markdown_metadata.get('meta', {})
213
+ return self.markdown_metadata.get("meta", {})
203
214
 
204
215
  @cached_property
205
216
  def markdown_metadata(self):
@@ -208,11 +219,11 @@ class Media:
208
219
 
209
220
  @property
210
221
  def markdown_metadata_filepath(self):
211
- return splitext(self.src_path)[0] + '.md'
222
+ return splitext(self.src_path)[0] + ".md"
212
223
 
213
224
  def _get_markdown_metadata(self):
214
225
  """Get metadata from filename.md."""
215
- meta = {'title': '', 'description': '', 'meta': {}}
226
+ meta = {"title": "", "description": "", "meta": {}}
216
227
  if isfile(self.markdown_metadata_filepath):
217
228
  meta.update(read_markdown(self.markdown_metadata_filepath))
218
229
  return meta
@@ -229,11 +240,14 @@ class Media:
229
240
  class Image(Media):
230
241
  """Gather all informations on an image file."""
231
242
 
232
- type = 'image'
243
+ type = "image"
233
244
 
234
245
  def __init__(self, filename, path, settings):
235
246
  super().__init__(filename, path, settings)
236
- imgformat = settings.get('img_format')
247
+ imgformat = settings.get("img_format")
248
+
249
+ # Register all formats
250
+ PILImage.init()
237
251
 
238
252
  if imgformat and PILImage.EXTENSION[self.src_ext] != imgformat.upper():
239
253
  # Find the extension that should match img_format
@@ -246,17 +260,17 @@ class Image(Media):
246
260
  def date(self):
247
261
  """The date from the EXIF DateTimeOriginal metadata if available, or
248
262
  from the file date."""
249
- return self.exif and self.exif.get('dateobj', None) or self._get_file_date()
263
+ return self.exif and self.exif.get("dateobj", None) or self._get_file_date()
250
264
 
251
265
  @cached_property
252
266
  def exif(self):
253
267
  """If not `None` contains a dict with the most common tags. For more
254
268
  information, see :ref:`simple-exif-data`.
255
269
  """
256
- datetime_format = self.settings['datetime_format']
270
+ datetime_format = self.settings["datetime_format"]
257
271
  return (
258
272
  get_exif_tags(self.raw_exif, datetime_format=datetime_format)
259
- if self.raw_exif and self.src_ext in ('.jpg', '.jpeg')
273
+ if self.raw_exif and self.src_ext in EXIF_EXTENSIONS
260
274
  else None
261
275
  )
262
276
 
@@ -271,18 +285,18 @@ class Image(Media):
271
285
 
272
286
  # If a title or description hasn't been obtained by other means, look
273
287
  # for the information in IPTC fields
274
- if not meta['title']:
275
- meta['title'] = self.file_metadata['iptc'].get('title', '')
276
- if not meta['description']:
277
- meta['description'] = self.file_metadata['iptc'].get('description', '')
288
+ if not meta["title"]:
289
+ meta["title"] = self.file_metadata["iptc"].get("title", "")
290
+ if not meta["description"]:
291
+ meta["description"] = self.file_metadata["iptc"].get("description", "")
278
292
 
279
293
  return meta
280
294
 
281
295
  @cached_property
282
296
  def raw_exif(self):
283
297
  """If not `None`, contains the raw EXIF tags."""
284
- if self.src_ext in ('.jpg', '.jpeg'):
285
- return self.file_metadata['exif']
298
+ if self.src_ext in EXIF_EXTENSIONS:
299
+ return self.file_metadata["exif"]
286
300
 
287
301
  @cached_property
288
302
  def size(self):
@@ -301,20 +315,20 @@ class Image(Media):
301
315
 
302
316
  def has_location(self):
303
317
  """True if location information is available for EXIF GPSInfo."""
304
- return self.exif is not None and 'gps' in self.exif
318
+ return self.exif is not None and "gps" in self.exif
305
319
 
306
320
 
307
321
  class Video(Media):
308
322
  """Gather all informations on a video file."""
309
323
 
310
- type = 'video'
324
+ type = "video"
311
325
 
312
326
  def __init__(self, filename, path, settings):
313
327
  super().__init__(filename, path, settings)
314
328
 
315
- if not settings['use_orig'] or not is_valid_html5_video(self.src_ext):
316
- video_format = settings['video_format']
317
- ext = '.' + video_format
329
+ if not settings["use_orig"] or not is_valid_html5_video(self.src_ext):
330
+ video_format = settings["video_format"]
331
+ ext = "." + video_format
318
332
  self.dst_filename = self.basename + ext
319
333
  self.mime = get_mime(ext)
320
334
  else:
@@ -323,12 +337,12 @@ class Video(Media):
323
337
  @cached_property
324
338
  def date(self):
325
339
  """The date from the Date metadata if available, or from the file date."""
326
- if 'date' in self.meta:
340
+ if "date" in self.meta:
327
341
  try:
328
342
  self.logger.debug(
329
343
  "Reading date from image metadata : %s", self.src_filename
330
344
  )
331
- return datetime.fromisoformat(self.meta['date'][0])
345
+ return datetime.fromisoformat(self.meta["date"][0])
332
346
  except Exception:
333
347
  self.logger.debug(
334
348
  "Reading date from image metadata failed : %s", self.src_filename
@@ -362,24 +376,24 @@ class Album:
362
376
  self.gallery = gallery
363
377
  self.settings = settings
364
378
  self.subdirs = dirnames
365
- self.output_file = settings['output_filename']
379
+ self.output_file = settings["output_filename"]
366
380
  self._thumbnail = None
367
381
 
368
- if path == '.':
369
- self.src_path = settings['source']
370
- self.dst_path = settings['destination']
382
+ if path == ".":
383
+ self.src_path = settings["source"]
384
+ self.dst_path = settings["destination"]
371
385
  else:
372
- self.src_path = join(settings['source'], path)
373
- self.dst_path = join(settings['destination'], path)
386
+ self.src_path = join(settings["source"], path)
387
+ self.dst_path = join(settings["destination"], path)
374
388
 
375
389
  self.logger = logging.getLogger(__name__)
376
390
 
377
391
  # optionally add index.html to the URLs
378
- self.url_ext = self.output_file if settings['index_in_url'] else ''
392
+ self.url_ext = self.output_file if settings["index_in_url"] else ""
379
393
 
380
394
  self.index_url = (
381
- url_from_path(os.path.relpath(settings['destination'], self.dst_path))
382
- + '/'
395
+ url_from_path(os.path.relpath(settings["destination"], self.dst_path))
396
+ + "/"
383
397
  + self.url_ext
384
398
  )
385
399
 
@@ -391,9 +405,9 @@ class Album:
391
405
  for f in filenames:
392
406
  ext = splitext(f)[1]
393
407
  media = None
394
- if ext.lower() in settings['img_extensions']:
408
+ if ext.lower() in settings["img_extensions"]:
395
409
  media = Image(f, self.path, settings)
396
- elif ext.lower() in settings['video_extensions']:
410
+ elif ext.lower() in settings["video_extensions"]:
397
411
  media = Video(f, self.path, settings)
398
412
 
399
413
  # Allow modification of the media, including overriding the class
@@ -410,13 +424,11 @@ class Album:
410
424
  signals.album_initialized.send(self)
411
425
 
412
426
  def __repr__(self):
413
- return "<{}>(path={!r}, title={!r})".format(
414
- self.__class__.__name__, self.path, self.title
415
- )
427
+ return f"<{self.__class__.__name__}>(path={self.path!r}, title={self.title!r})"
416
428
 
417
429
  def __str__(self):
418
- return f'{self.path} : ' + ', '.join(
419
- f'{count} {_type}s' for _type, count in self.medias_count.items()
430
+ return f"{self.path} : " + ", ".join(
431
+ f"{count} {_type}s" for _type, count in self.medias_count.items()
420
432
  )
421
433
 
422
434
  def __len__(self):
@@ -428,27 +440,27 @@ class Album:
428
440
  @cached_property
429
441
  def description(self):
430
442
  """Description extracted from the Markdown index.md file."""
431
- return self.markdown_metadata.get('description', '')
443
+ return self.markdown_metadata.get("description", "")
432
444
 
433
445
  @cached_property
434
446
  def title(self):
435
447
  """Title extracted from the Markdown index.md file."""
436
- title = self.markdown_metadata.get('title', '')
437
- path = self.path if self.path != '.' else self.src_path
448
+ title = self.markdown_metadata.get("title", "")
449
+ path = self.path if self.path != "." else self.src_path
438
450
  return title if title else os.path.basename(path)
439
451
 
440
452
  @cached_property
441
453
  def meta(self):
442
454
  """Other metadata extracted from the Markdown index.md file."""
443
- return self.markdown_metadata.get('meta', {})
455
+ return self.markdown_metadata.get("meta", {})
444
456
 
445
457
  @cached_property
446
458
  def author(self):
447
459
  """Author extracted from the Markdown index.md file or settings."""
448
460
  try:
449
- return self.meta['author'][0]
461
+ return self.meta["author"][0]
450
462
  except KeyError:
451
- return self.settings.get('author')
463
+ return self.settings.get("author")
452
464
 
453
465
  @property
454
466
  def markdown_metadata_filepath(self):
@@ -457,7 +469,7 @@ class Album:
457
469
  @cached_property
458
470
  def markdown_metadata(self):
459
471
  """Get metadata from filename.md: title, description, meta."""
460
- meta = {'title': '', 'description': '', 'meta': {}}
472
+ meta = {"title": "", "description": "", "meta": {}}
461
473
  if isfile(self.markdown_metadata_filepath):
462
474
  meta.update(read_markdown(self.markdown_metadata_filepath))
463
475
  return meta
@@ -467,63 +479,74 @@ class Album:
467
479
  check_or_create_dir(self.dst_path)
468
480
 
469
481
  if self.medias:
470
- check_or_create_dir(join(self.dst_path, self.settings['thumb_dir']))
482
+ check_or_create_dir(join(self.dst_path, self.settings["thumb_dir"]))
471
483
 
472
- if self.medias and self.settings['keep_orig']:
473
- self.orig_path = join(self.dst_path, self.settings['orig_dir'])
484
+ if self.medias and self.settings["keep_orig"]:
485
+ self.orig_path = join(self.dst_path, self.settings["orig_dir"])
474
486
  check_or_create_dir(self.orig_path)
475
487
 
476
488
  def sort_subdirs(self, albums_sort_attr):
477
489
  if self.subdirs:
478
490
  if not albums_sort_attr:
479
- albums_sort_attr = self.settings['albums_sort_attr']
480
- reverse = self.settings['albums_sort_reverse']
491
+ albums_sort_attr = self.settings["albums_sort_attr"]
492
+ reverse = self.settings["albums_sort_reverse"]
481
493
 
482
- if 'sort' in self.meta:
483
- albums_sort_attr = self.meta['sort'][0]
484
- if albums_sort_attr[0] == '-':
494
+ if "sort" in self.meta:
495
+ # override default sort order from settings
496
+ albums_sort_attr = self.meta["sort"][0]
497
+ if albums_sort_attr[0] == "-":
485
498
  albums_sort_attr = albums_sort_attr[1:]
486
499
  reverse = True
487
500
  else:
488
501
  reverse = False
489
502
 
490
- root_path = self.path if self.path != '.' else ''
491
- if albums_sort_attr.startswith("meta."):
492
- meta_key = albums_sort_attr.split(".", 1)[1]
503
+ root_path = self.path if self.path != "." else ""
493
504
 
494
- def sort_key(s):
495
- album = self.gallery.albums[join(root_path, s)]
496
- return album.meta.get(meta_key, [''])[0]
505
+ def sort_key(s):
506
+ sort_attr = albums_sort_attr
507
+ if not isinstance(sort_attr, list):
508
+ sort_attr = [sort_attr]
497
509
 
498
- else:
510
+ album = self.gallery.albums[join(root_path, s)]
499
511
 
500
- def sort_key(s):
501
- album = self.gallery.albums[join(root_path, s)]
502
- return getattr(album, albums_sort_attr)
512
+ for k in sort_attr:
513
+ try:
514
+ if k.startswith("meta."):
515
+ meta_key = k.split(".", 1)[1]
516
+ return album.meta.get(meta_key)[0]
517
+ else:
518
+ return getattr(album, k)
519
+ except AttributeError:
520
+ continue
521
+ except TypeError:
522
+ continue
523
+ return ""
503
524
 
504
- key = natsort_keygen(key=sort_key, alg=ns.LOCALE)
525
+ key = natsort_keygen(key=sort_key, alg=ns.SIGNED | ns.LOCALE)
505
526
  self.subdirs.sort(key=key, reverse=reverse)
506
527
 
507
528
  signals.albums_sorted.send(self)
508
529
 
509
530
  def sort_medias(self, medias_sort_attr):
510
531
  if self.medias:
511
- if medias_sort_attr == 'filename':
512
- medias_sort_attr = 'dst_filename'
532
+ if medias_sort_attr == "filename":
533
+ medias_sort_attr = "dst_filename"
513
534
 
514
- if medias_sort_attr == 'date':
535
+ if medias_sort_attr == "date":
515
536
  key = lambda s: s.date or datetime.now()
516
- elif medias_sort_attr.startswith('meta.'):
537
+ elif medias_sort_attr.startswith("meta."):
517
538
  meta_key = medias_sort_attr.split(".", 1)[1]
518
539
  key = natsort_keygen(
519
- key=lambda s: s.meta.get(meta_key, [''])[0], alg=ns.LOCALE
540
+ key=lambda s: s.meta.get(meta_key, [""])[0],
541
+ alg=ns.SIGNED | ns.LOCALE,
520
542
  )
521
543
  else:
522
544
  key = natsort_keygen(
523
- key=lambda s: getattr(s, medias_sort_attr), alg=ns.LOCALE
545
+ key=lambda s: getattr(s, medias_sort_attr),
546
+ alg=ns.SIGNED | ns.LOCALE,
524
547
  )
525
548
 
526
- self.medias.sort(key=key, reverse=self.settings['medias_sort_reverse'])
549
+ self.medias.sort(key=key, reverse=self.settings["medias_sort_reverse"])
527
550
 
528
551
  signals.medias_sorted.send(self)
529
552
 
@@ -531,14 +554,14 @@ class Album:
531
554
  def images(self):
532
555
  """List of images (:class:`~sigal.gallery.Image`)."""
533
556
  for media in self.medias:
534
- if media.type == 'image':
557
+ if media.type == "image":
535
558
  yield media
536
559
 
537
560
  @property
538
561
  def videos(self):
539
562
  """List of videos (:class:`~sigal.gallery.Video`)."""
540
563
  for media in self.medias:
541
- if media.type == 'video':
564
+ if media.type == "video":
542
565
  yield media
543
566
 
544
567
  @property
@@ -546,7 +569,7 @@ class Album:
546
569
  """List of :class:`~sigal.gallery.Album` objects for each
547
570
  sub-directory.
548
571
  """
549
- root_path = self.path if self.path != '.' else ''
572
+ root_path = self.path if self.path != "." else ""
550
573
  return [self.gallery.albums[join(root_path, path)] for path in self.subdirs]
551
574
 
552
575
  @property
@@ -556,8 +579,8 @@ class Album:
556
579
  @property
557
580
  def url(self):
558
581
  """URL of the album, relative to its parent."""
559
- url = self.name.encode('utf-8')
560
- return url_quote(url) + '/' + self.url_ext
582
+ url = self.name.encode("utf-8")
583
+ return url_quote(url) + "/" + self.url_ext
561
584
 
562
585
  @property
563
586
  def thumbnail(self):
@@ -568,7 +591,7 @@ class Album:
568
591
  return self._thumbnail
569
592
 
570
593
  # Test the thumbnail from the Markdown file.
571
- thumbnail = self.meta.get('thumbnail', [''])[0]
594
+ thumbnail = self.meta.get("thumbnail", [""])[0]
572
595
 
573
596
  if thumbnail and isfile(join(self.src_path, thumbnail)):
574
597
  self._thumbnail = url_from_path(
@@ -580,18 +603,18 @@ class Album:
580
603
  # find and return the first landscape image
581
604
  for f in self.medias:
582
605
  ext = splitext(f.dst_filename)[1]
583
- if ext.lower() not in self.settings['img_extensions']:
606
+ if ext.lower() not in self.settings["img_extensions"]:
584
607
  continue
585
608
 
586
609
  # Use f.size if available as it is quicker (in cache), but
587
610
  # fallback to the size of src_path if dst_path is missing
588
611
  size = f.input_size
589
612
  if size is None:
590
- size = f.file_metadata['size']
613
+ size = f.file_metadata["size"]
591
614
 
592
- if size['width'] > size['height']:
615
+ if size["width"] > size["height"]:
593
616
  try:
594
- self._thumbnail = url_quote(self.name) + '/' + f.thumbnail
617
+ self._thumbnail = url_quote(self.name) + "/" + f.thumbnail
595
618
  except Exception as e:
596
619
  self.logger.info(
597
620
  "Failed to get thumbnail for %s: %s", f.dst_filename, e
@@ -610,7 +633,7 @@ class Album:
610
633
  if media.thumbnail is not None:
611
634
  try:
612
635
  self._thumbnail = (
613
- url_quote(self.name) + '/' + media.thumbnail
636
+ url_quote(self.name) + "/" + media.thumbnail
614
637
  )
615
638
  except Exception as e:
616
639
  self.logger.info(
@@ -633,7 +656,7 @@ class Album:
633
656
  if not self._thumbnail:
634
657
  for path, album in self.gallery.get_albums(self.path):
635
658
  if album.thumbnail:
636
- self._thumbnail = url_quote(self.name) + '/' + album.thumbnail
659
+ self._thumbnail = url_quote(self.name) + "/" + album.thumbnail
637
660
  self.logger.debug(
638
661
  "Using thumbnail from sub-directory for %r : %s",
639
662
  self,
@@ -641,7 +664,7 @@ class Album:
641
664
  )
642
665
  return self._thumbnail
643
666
 
644
- self.logger.error('Thumbnail not found for %r', self)
667
+ self.logger.error("Thumbnail not found for %r", self)
645
668
 
646
669
  @property
647
670
  def random_thumbnail(self):
@@ -655,18 +678,18 @@ class Album:
655
678
  """List of ``(url, title)`` tuples defining the current breadcrumb
656
679
  path.
657
680
  """
658
- if self.path == '.':
681
+ if self.path == ".":
659
682
  return []
660
683
 
661
684
  path = self.path
662
- breadcrumb = [((self.url_ext or '.'), self.title)]
685
+ breadcrumb = [((self.url_ext or "."), self.title)]
663
686
 
664
687
  while True:
665
- path = os.path.normpath(os.path.join(path, '..'))
666
- if path == '.':
688
+ path = os.path.normpath(os.path.join(path, ".."))
689
+ if path == ".":
667
690
  break
668
691
 
669
- url = url_from_path(os.path.relpath(path, self.path)) + '/' + self.url_ext
692
+ url = url_from_path(os.path.relpath(path, self.path)) + "/" + self.url_ext
670
693
  breadcrumb.append((url, self.gallery.albums[path].title))
671
694
 
672
695
  breadcrumb.reverse()
@@ -685,30 +708,30 @@ class Album:
685
708
 
686
709
 
687
710
  class Gallery:
688
- def __init__(self, settings, ncpu=None, quiet=False):
711
+ def __init__(self, settings, ncpu=None, show_progress=False):
689
712
  self.settings = settings
690
713
  self.logger = logging.getLogger(__name__)
691
714
  self.stats = defaultdict(int)
692
715
  self.init_pool(ncpu)
693
- check_or_create_dir(settings['destination'])
716
+ check_or_create_dir(settings["destination"])
694
717
 
695
- if settings['max_img_pixels']:
696
- PILImage.MAX_IMAGE_PIXELS = settings['max_img_pixels']
718
+ if settings["max_img_pixels"]:
719
+ PILImage.MAX_IMAGE_PIXELS = settings["max_img_pixels"]
697
720
 
698
721
  # Build the list of directories with images
699
722
  albums = self.albums = {}
700
- src_path = self.settings['source']
723
+ src_path = self.settings["source"]
701
724
 
702
- ignore_dirs = settings['ignore_directories']
703
- ignore_files = settings['ignore_files']
725
+ ignore_dirs = settings["ignore_directories"]
726
+ ignore_files = settings["ignore_files"]
704
727
 
705
728
  progressChars = cycle(["/", "-", "\\", "|"])
706
- show_progress = (
707
- not quiet
708
- and self.logger.getEffectiveLevel() >= logging.WARNING
709
- and os.isatty(sys.stdout.fileno())
710
- )
711
- self.progressbar_target = None if show_progress else Devnull()
729
+ try:
730
+ isatty = os.isatty(sys.stdout.fileno())
731
+ except io.UnsupportedOperation:
732
+ isatty = False
733
+
734
+ self.progressbar_target = None if show_progress and isatty else Devnull()
712
735
 
713
736
  for path, dirs, files in os.walk(src_path, followlinks=True, topdown=False):
714
737
  if show_progress:
@@ -719,7 +742,12 @@ class Gallery:
719
742
  if ignore_dirs and any(
720
743
  fnmatch.fnmatch(relpath, ignore) for ignore in ignore_dirs
721
744
  ):
722
- self.logger.info('Ignoring %s', relpath)
745
+ self.logger.info("Ignoring %s", relpath)
746
+ # Remove sub-directories
747
+ for d in dirs[:]:
748
+ path = join(relpath, d) if relpath != "." else d
749
+ if path in albums.keys():
750
+ del albums[path]
723
751
  continue
724
752
 
725
753
  # Remove files that match the ignore_files settings
@@ -728,22 +756,22 @@ class Gallery:
728
756
  for ignore in ignore_files:
729
757
  files_path -= set(fnmatch.filter(files_path, ignore))
730
758
 
731
- self.logger.debug('Files before filtering: %r', files)
759
+ self.logger.debug("Files before filtering: %r", files)
732
760
  files = [os.path.split(f)[1] for f in files_path]
733
- self.logger.debug('Files after filtering: %r', files)
761
+ self.logger.debug("Files after filtering: %r", files)
734
762
 
735
763
  # Remove sub-directories that have been ignored in a previous
736
764
  # iteration (as topdown=False, sub-directories are processed before
737
765
  # their parent
738
766
  for d in dirs[:]:
739
- path = join(relpath, d) if relpath != '.' else d
767
+ path = join(relpath, d) if relpath != "." else d
740
768
  if path not in albums.keys():
741
769
  dirs.remove(d)
742
770
 
743
771
  album = Album(relpath, settings, dirs, files, self)
744
772
 
745
773
  if not album.medias and not album.albums:
746
- self.logger.info('Skip empty album: %r', album)
774
+ self.logger.info("Skip empty album: %r", album)
747
775
  else:
748
776
  album.create_output_directories()
749
777
  albums[relpath] = album
@@ -753,27 +781,27 @@ class Gallery:
753
781
 
754
782
  with progressbar(
755
783
  albums.values(),
756
- label="%16s" % "Sorting albums",
784
+ label="{:>16s}".format("Sorting albums"),
757
785
  file=self.progressbar_target,
758
786
  ) as progress_albums:
759
787
  for album in progress_albums:
760
- album.sort_subdirs(settings['albums_sort_attr'])
788
+ album.sort_subdirs(settings["albums_sort_attr"])
761
789
 
762
790
  with progressbar(
763
791
  albums.values(),
764
- label="%16s" % "Sorting media",
792
+ label="{:>16s}".format("Sorting medias"),
765
793
  file=self.progressbar_target,
766
794
  ) as progress_albums:
767
795
  for album in progress_albums:
768
- album.sort_medias(settings['medias_sort_attr'])
796
+ album.sort_medias(settings["medias_sort_attr"])
769
797
 
770
- self.logger.debug('Albums:\n%r', albums.values())
798
+ self.logger.debug("Albums:\n%r", albums.values())
771
799
  signals.gallery_initialized.send(self)
772
800
 
773
801
  @property
774
802
  def title(self):
775
803
  """Title of the gallery."""
776
- return self.settings['title'] or self.albums['.'].title
804
+ return self.settings["title"] or self.albums["."].title
777
805
 
778
806
  def init_pool(self, ncpu):
779
807
  try:
@@ -787,16 +815,15 @@ class Gallery:
787
815
  try:
788
816
  ncpu = int(ncpu)
789
817
  except ValueError:
790
- self.logger.error('ncpu should be an integer value')
818
+ self.logger.error("ncpu should be an integer value")
791
819
  ncpu = cpu_count
792
820
 
793
821
  self.logger.info("Using %s cores", ncpu)
794
822
  if ncpu > 1:
795
-
796
823
  self.pool = multiprocessing.Pool(
797
824
  processes=ncpu,
798
825
  initializer=pool_init,
799
- initargs=(self.settings['max_img_pixels'],),
826
+ initargs=(self.settings["max_img_pixels"],),
800
827
  )
801
828
  else:
802
829
  self.pool = None
@@ -837,12 +864,12 @@ class Gallery:
837
864
  f for album in albums for f in self.process_dir(album, force=force)
838
865
  ]
839
866
  except KeyboardInterrupt:
840
- sys.exit('Interrupted')
867
+ sys.exit("Interrupted")
841
868
 
842
869
  bar_opt = {
843
- 'label': "Processing files",
844
- 'show_pos': True,
845
- 'file': self.progressbar_target,
870
+ "label": "Processing files",
871
+ "show_pos": True,
872
+ "file": self.progressbar_target,
846
873
  }
847
874
 
848
875
  if self.pool:
@@ -854,7 +881,7 @@ class Gallery:
854
881
  bar.update(1)
855
882
  except KeyboardInterrupt:
856
883
  self.pool.terminate()
857
- sys.exit('Interrupted')
884
+ sys.exit("Interrupted")
858
885
  except pickle.PicklingError:
859
886
  self.logger.critical(
860
887
  "Failed to process files with the multiprocessing feature."
@@ -862,7 +889,7 @@ class Gallery:
862
889
  "defined in the settings file, which can't be serialized.",
863
890
  exc_info=True,
864
891
  )
865
- sys.exit('Abort')
892
+ sys.exit("Abort")
866
893
  finally:
867
894
  self.pool.close()
868
895
  self.pool.join()
@@ -876,14 +903,15 @@ class Gallery:
876
903
  ]
877
904
  self.remove_files(failed_files)
878
905
 
879
- if self.settings['write_html']:
906
+ if self.settings["write_html"]:
880
907
  album_writer = AlbumPageWriter(self.settings, index_title=self.title)
908
+ album_writer.copy_theme_files()
881
909
  album_list_writer = AlbumListPageWriter(
882
910
  self.settings, index_title=self.title
883
911
  )
884
912
  with progressbar(
885
913
  self.albums.values(),
886
- label="%16s" % "Writing files",
914
+ label="{:>16s}".format("Writing files"),
887
915
  item_show_func=log_func,
888
916
  show_eta=False,
889
917
  file=self.progressbar_target,
@@ -892,7 +920,7 @@ class Gallery:
892
920
  if album.albums:
893
921
  if album.medias:
894
922
  self.logger.warning(
895
- "Album %s contains sub-albums and images. "
923
+ "Album '%s' contains sub-albums and images. "
896
924
  "Please move images to their own sub-album. "
897
925
  "Images in album %s will not be visible.",
898
926
  album.title,
@@ -901,31 +929,33 @@ class Gallery:
901
929
  album_list_writer.write(album)
902
930
  else:
903
931
  album_writer.write(album)
904
- print('')
932
+ print("")
905
933
 
906
934
  signals.gallery_build.send(self)
907
935
 
908
936
  def remove_files(self, medias):
909
- self.logger.error('Some files have failed to be processed:')
937
+ self.logger.error("Some files have failed to be processed:")
910
938
  for media in medias:
911
- self.logger.error(' - %s', media.dst_filename)
939
+ self.logger.error(" - %s", media.dst_filename)
912
940
  album = self.albums[media.path]
913
941
  for f in album.medias:
914
942
  if f.dst_filename == media.dst_filename:
915
- self.stats[f.type + '_failed'] += 1
943
+ self.stats[f.type + "_failed"] += 1
916
944
  album.medias.remove(f)
917
945
  break
918
946
  self.logger.error(
919
947
  'You can run "sigal build" in verbose (--verbose) or'
920
- ' debug (--debug) mode to get more details.'
948
+ " debug (--debug) mode to get more details."
921
949
  )
922
950
 
923
951
  def process_dir(self, album, force=False):
924
952
  """Process a list of images in a directory."""
925
953
  for f in album:
926
- if isfile(f.dst_path) and not force:
954
+ if isfile(f.dst_path) and not should_reprocess_album(
955
+ album.path, album.name, force
956
+ ):
927
957
  self.logger.info("%s exists - skipping", f.dst_filename)
928
- self.stats[f.type + '_skipped'] += 1
958
+ self.stats[f.type + "_skipped"] += 1
929
959
  else:
930
960
  self.stats[f.type] += 1
931
961
  yield f
@@ -938,9 +968,9 @@ def pool_init(max_img_pixels):
938
968
 
939
969
  def process_file(media):
940
970
  processor = None
941
- if media.type == 'image':
971
+ if media.type == "image":
942
972
  processor = process_image
943
- elif media.type == 'video':
973
+ elif media.type == "video":
944
974
  processor = process_video
945
975
 
946
976
  # Allow overriding of the processor
@@ -952,7 +982,7 @@ def process_file(media):
952
982
  if processor:
953
983
  return processor(media)
954
984
  else:
955
- logging.warning('Processor not found for media %s', media.path)
985
+ logging.warning("Processor not found for media %s", media.path)
956
986
  return Status.FAILURE
957
987
 
958
988