1
# Phatch - Photo Batch Processor
2
# Copyright (C) 2009 www.stani.be
4
# This program is free software: you can redistribute it and/or modify
5
# it under the terms of the GNU General Public License as published by
6
# the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
# GNU General Public License for more details.
14
# You should have received a copy of the GNU General Public License
15
# along with this program. If not, see http://www.gnu.org/licenses/
17
# Phatch recommends SPE (http://pythonide.stani.be) for editing python files.
24
__builtins__['_'] = unicode
37
from cStringIO import StringIO
38
from datetime import timedelta
41
from data.version import VERSION
42
from lib import formField
43
from lib import metadata
44
from lib import openImage
46
from lib.odict import ReadOnlyDict
47
from lib.unicoding import ensure_unicode, exception_to_unicode, ENCODING
51
from message import send
54
PROGRESS_MESSAGE = 'In: %s%s\nFile' % (' ' * 100, '.')
55
ERROR_MESSAGE = """%(number)s: %(message)s
62
WARN_MESSAGE = """%(number)s: %(message)s
67
SEE_LOG = _('See "%s" for more details.') % _('Show Log')
68
TREE_HEADERS = ['filename', 'type', 'folder', 'subfolder', 'root',
70
TREE_VARS = set(TREE_HEADERS).union(pil.BASE_VARS)
71
TREE_HEADERS += ['index', 'folderindex']
72
ERROR_INCOMPATIBLE_ACTIONLIST = \
73
_('Sorry, the action list seems incompatible with %(name)s %(version)s.')
75
ERROR_UNSAFE_ACTIONLIST_INTRO = _('This action list is unsafe:')
76
ERROR_UNSAFE_ACTIONLIST_DISABLE_SAFE = \
77
_('Disable Safe Mode in the Tools menu if you trust this action list.')
78
ERROR_UNSAFE_ACTIONLIST_ACCEPT = \
79
_("Never run action lists from untrusted sources.") + ' ' +\
80
_("Please check if this action list doesn't contain harmful code.")
85
class PathError(Exception):
87
def __init__(self, filename):
88
"""PathError for invalid path.
90
:param filename: filename of the invalid path
93
self.filename = filename
96
return _('"%s" is not a valid path.') % self.filename
102
"""Verify user paths and import all actions. This function should
103
be called at the start."""
104
from config import verify_app_user_paths
105
verify_app_user_paths()
112
def _init_log_file():
113
"""Reset ERROR_LOG_COUNTER and create the ERROR_LOG_FILE."""
114
global ERROR_LOG_COUNTER, WARNING_LOG_COUNTER
115
ERROR_LOG_COUNTER = 0
116
WARNING_LOG_COUNTER = 0
117
handler = logging.FileHandler(
118
filename=ct.USER_LOG_PATH,
122
handler.setFormatter(logging.Formatter('%(levelname)s %(message)s'))
123
logger = logging.getLogger()
124
for h in logger.handlers:
125
logger.removeHandler(h)
126
logger.addHandler(handler)
129
def log_warning(message, input, action=None):
130
"""Writer warning message to log file.
132
Helper function for :func:`flush_log`, :func:`process_error`.
134
:param message: error message
135
:type message: string
136
:param input: input image
138
:returns: error log details
141
global WARNING_LOG_COUNTER
143
action = pprint.pformat(action.dump())
144
details = WARN_MESSAGE % {
145
'number': WARNING_LOG_COUNTER + 1,
150
logging.warn(details)
151
WARNING_LOG_COUNTER += 1
155
def log_error(message, input, action=None):
156
"""Writer error message to log file.
158
Helper function for :func:`flush_log`, :func:`process_error`.
160
:param message: error message
161
:type message: string
162
:param input: input image
164
:returns: error log details
167
global ERROR_LOG_COUNTER
169
action = pprint.pformat(action.dump())
170
details = ERROR_MESSAGE % {
171
'number': ERROR_LOG_COUNTER + 1,
175
'details': traceback.format_exc(),
177
logging.error(details)
178
ERROR_LOG_COUNTER += 1
184
def get_vars(actions):
185
"""Extract all used variables from actions.
187
:param actions: list of actions
188
:type actions: list of dict
191
for action in actions:
192
vars.extend(action.metadata)
193
for field in action._get_fields().values():
194
safe.extend_vars(vars, field.get_as_string())
198
def assert_safe(actions):
199
test_info = metadata.InfoTest()
202
for action in actions:
204
if action.label == 'Geek':
206
for label, field in action._get_fields().items():
207
if label.startswith('_') \
208
or isinstance(field, formField.BooleanField)\
209
or isinstance(field, formField.ChoiceField)\
210
or isinstance(field, formField.SliderField):
213
field.assert_safe(label, test_info)
214
except Exception, details:
215
warning_action += ' %s: %s\n'\
216
% (label, exception_to_unicode(details))
218
warning += '%s %s:\n%s' % (_(action.label), _('Action'),
223
warning += '%s\n' % (_('Geek actions are not allowed in safe mode.'))
226
#---collect image files
229
def filter_image_infos(folder, extensions, files, root, info_file):
230
"""Filter image files by extension and verify if they are files. It
231
returns a list of info dictionaries which are generated by
232
:method:`InfoPil.dump`::
237
'folder': u'/home/stani',
238
'foldername': u'stani',
242
'monthname': 'March',
243
'path': '/home/stani/beach.jpg',
249
'weekdayname': 'Friday',
253
``$`` is the index of the file within a folder.
255
Helper function for :func:`get_image_infos_from_folder`
257
:param folder: folder path (recursion dependent)
259
:param extensions: extensions (without ``.``)
260
:type extensions: list of strings
261
:param files: list of filenames without folder path
262
:type files: list of strings
263
:param root: root folder path (independent from recursion)
265
:returns: list of image file info
266
:rtype: list of dictionaries
268
#check if extensions work ok! '.png' vs 'png'
269
files.sort(key=string.lower)
273
info = info_file.dump((os.path.join(folder, file), root))
274
if os.path.isfile(info['path']) and info['type'].lower() in extensions:
275
info['folderindex'] = folder_index
281
def get_image_infos_from_folder(folder, info_file, extensions, recursive):
282
"""Get all image info dictionaries from a specific folder.
284
:param folder: top folder path
286
:param extensions: extensions (without ``.``)
287
:type extensions: list of strings
288
:param recursive: include subfolders
289
:type recursive: bool
290
:returns: list of image file info
291
:rtype: list of dictionaries
293
Helper function for :func:`get_image_infos`
295
.. see also:: :func:`filter_image_infos`
297
source_parent = folder # do not change (independent of recursion!)
298
# root = os.path.dirname(folder) #do not change (independent of recursion!)
301
for folder, dirs, files in os.walk(folder):
302
image_infos.extend(filter_image_infos(folder, extensions,
303
files, source_parent, info_file))
306
return filter_image_infos(folder, extensions, os.listdir(folder),
307
source_parent, info_file)
310
def get_image_infos(paths, info_file, extensions, recursive):
311
"""Get all image info dictionaries from a mix of folder and file paths.
313
:param paths: file and/or folderpaths
314
:type paths: list of strings
315
:param extensions: extensions (without ``.``)
316
:type extensions: list of strings
317
:param recursive: include subfolders
318
:type recursive: bool
319
:returns: list of image file info
320
:rtype: list of dictionaries
322
.. see also:: :func:`get_image_infos_from_folder`
326
path = os.path.abspath(path.strip())
327
if os.path.isfile(path):
329
info = {'folderindex': 0}
330
info.update(info_file.dump(path))
331
image_infos.append(info)
332
elif os.path.isdir(path):
333
#folder of image files
334
image_infos.extend(get_image_infos_from_folder(
335
path, info_file, extensions, recursive))
337
#not a file or folder?! probably does not exist
338
send.frame_show_error('Sorry, "%s" is not a valid path.' \
339
% ensure_unicode(path))
341
image_infos.sort(key=operator.itemgetter('path'))
347
def check_actionlist_file_only(actions):
348
"""Check whether the action list only exist of file operations
349
(such as copy, rename, ...)
351
:param actions: actions of the action list
352
:type: list of :class:`core.models.Action`
353
:returns: True if only file operations, False otherwise
356
>>> from actions import canvas, rename
357
>>> check_actionlist_file_only([canvas.Action()])
359
>>> check_actionlist_file_only([rename.Action()])
362
for action in actions:
363
if not ('file' in action.tags):
368
def check_actionlist(actions, settings):
369
"""Verifies action list before executing. It checks whether:
371
* the action list is not empty
372
* all actions are not disabled
373
* if there is a save action at the end or only file actions
374
* overwriting images is forced
376
:param actions: actions of the action list
377
:type actions: list of :class:`core.models.Action`
378
:param settings: execution settings
379
:type settings: dictionary
381
>>> settings = {'no_save':False}
382
>>> check_actionlist([], settings) is None
384
>>> from actions import canvas, save
385
>>> canvas_action = canvas.Action()
386
>>> save_action = save.Action()
387
>>> check_actionlist([canvas_action,save_action],
388
... {'no_save':False}) is None
390
>>> check_actionlist([canvas_action], settings) is None
392
>>> settings = {'no_save':True}
393
>>> check_actionlist([canvas_action], settings) is None
395
>>> settings['overwrite_existing_images_forced']
398
.. see also:: :func:`check_actionlist_file_only`
400
#Check if there is something to do
402
send.frame_show_error('%s %s' % (_('Nothing to do.'),
403
_('The action list is empty.')))
405
#Check if the actionlist is safe
406
if formField.get_safe():
407
warnings = assert_safe(actions)
409
send.frame_show_error('%s\n\n%s\n%s' % (
410
ERROR_UNSAFE_ACTIONLIST_INTRO, warnings,
411
ERROR_UNSAFE_ACTIONLIST_DISABLE_SAFE))
413
#Skip disabled actions
414
actions = [action for action in actions if action.is_enabled()]
416
send.frame_show_error('%s %s' % (_('Nothing to do.'),
417
_('There is no action enabled.')))
419
#Check if there is a save statement
420
last_action = actions[-1]
421
if not (last_action.valid_last or check_actionlist_file_only(actions)\
422
or settings['no_save']):
423
send.frame_append_save_action(actions)
425
#Check if overwrite is forced
426
settings['overwrite_existing_images_forced'] = \
427
(not settings['no_save']) and \
428
actions[-1].is_overwrite_existing_images_forced()
432
def verify_images(image_infos, repeat):
433
"""Filter invalid images out.
435
Verify if images are not corrupt. Show the invalid images to
436
the user. If no valid images are found, show an error to the user.
437
Otherwise show the valid images to the user.
439
:param image_infos: list of image info dictionaries
440
:type image_infos: list of dictionaries
441
:returns: None for error, valid image info dictionaries otherwise
444
send.frame_show_progress(title=_("Checking images"),
445
parent_max=len(image_infos),
446
message=PROGRESS_MESSAGE)
450
for index, image_info in enumerate(image_infos):
452
send.progress_update_filename(result, index, image_info['path'])
453
if not result['keepgoing']:
455
openImage.verify_image(image_info, valid, invalid)
456
send.progress_close()
457
#show invalid files to the user
460
send.frame_show_files_message(result,
461
message=_('Phatch can not handle %d image(s):') % len(invalid),
462
title=ct.FRAME_TITLE % ('', _('Invalid images')),
466
#Display an error when no files are left
468
send.frame_show_error(_("Sorry, no valid files found"))
471
for index, image_info in enumerate(valid):
472
image_info['index'] = index * repeat
473
#show valid images to the user in tree structure
475
send.frame_show_image_tree(result, valid,
476
widths=(200, 40, 200, 200, 200, 200, 60),
477
headers=TREE_HEADERS,
478
ok_label=_('C&ontinue'), buttons=True)
485
def get_paths_and_settings(paths, settings, drop=False):
486
"""Ask the user for paths and settings. In the GUI this shows
487
the execute dialog box.
489
:param paths: initial value of the paths (eg to fill in dialog)
490
:type paths: list of strings
491
:param settings: settings
492
:type settings: dictionary
495
True in case files were dropped or phatch is started as a
500
if drop or (paths is None):
502
send.frame_show_execute_dialog(result, settings, paths)
505
paths = settings['paths']
507
send.frame_show_error(_('No files or folder selected.'))
512
def get_photo(info_file, info_not_file, result):
513
"""Get a :class:`core.pil.Photo` instance from a file. If there is an
514
error opening the file, func:`process_error` will be called.
516
:param info_file: file information
517
:type info_file: dictionary
518
:param info_not_file: image information not related to file
519
:type info_not_file: string
522
settings to send to progress dialog box
523
(such as ``stop for errors``)
526
:returns: photo, result
530
photo = pil.Photo(info_file, info_not_file)
531
result['skip'] = False
532
result['abort'] = False
534
except Exception, details:
535
reason = exception_to_unicode(details)
537
message = u'%s: %s:\n%s' % (_('Unable to open file'),
538
info_file['path'], reason)
542
return process_error(photo, message, info_file['path'], action,
548
def process_error(photo, message, image_file, action, result, ignore):
549
"""Logs error to file with :func:`log_error` and show dialog box
550
allowing the user to skip, abort or ignore.
552
Helper function for :func:`get_photo` and `apply_action`.
555
:type photo: class:`core.pil.Photo`
556
:param message: error message
557
:type message: string
558
:param image_file: absolute path of the image
559
:type image_file: string
560
:param result: settings for dialog (eg ``stop_for_errors``)
561
:type result: dictionary
562
:returns: photo, result
565
log_error(message, image_file, action)
567
if result['stop_for_errors']:
568
send.frame_show_progress_error(result, message, ignore=ignore)
570
answer = result['answer']
571
if answer == _('abort'):
572
#send.progress_close()
573
result['skip'] = False
574
result['abort'] = True
576
result['last_answer'] = answer
577
if answer == _('skip'):
578
result['skip'] = True
579
result['abort'] = False
581
elif result['last_answer'] == _('skip'):
582
result['skip'] = True
583
result['abort'] = False
585
result['skip'] = False
586
result['abort'] = False
590
def flush_log(photo, image_file, action=None):
591
"""Flushes non fatal errors/warnings with :func:`log_error`
592
and warnings that have been logged from the photo to the error log
595
:param photo: photo which has photo.log
596
:type photo: class:`core.pil.Photo`
597
:param image_file: absolute path of the image
598
:type image_file: string
599
:param action: action which was involved in the error (optional)
600
:type action: :class:`core.models.Action`
602
log = photo.get_log()
604
log_warning(log, image_file, action)
608
def init_actions(actions):
609
"""Initializes all actions. Shows an error to the user if an
610
action fails to initialize.
612
:param actions: actions
613
:type actions: list of :class:`core.models.Action`
614
:returns: False, if one action fails, True otherwise
617
for action in actions:
620
except Exception, details:
621
reason = exception_to_unicode(details)
622
message = u'%s\n\n%s' % (
623
_("Can not apply action %(a)s:") \
624
% {'a': _(action.label)}, reason)
625
send.frame_show_error(message)
630
def apply_action_to_photo(action, photo, read_only_settings, cache,
632
"""Apply a single action to a photo. It uses :func:`log_error` for
633
non fatal errors or :func:`process_error` for serious errors. The
634
settings are read only as the actions don't have permission to
637
:param action: action
638
:type action: :class:`core.models.Action`
640
:type photo: :class:`core.pil.Photo`
641
:param read_only_settings: read only settings
642
:type read_only_settings: :class:`lib.odict.ReadOnlyDict`
643
:param cache: cache for data which is usefull across photos
644
:type cache: dictionary
645
:param image_file: filename reference during error logging
646
:type image_file: string
647
:param result: settings for dialog (eg ``stop_for_errors``)
648
:type result: dictionary
651
photo = action.apply(photo, read_only_settings, cache)
652
result['skip'] = False
653
result['abort'] = False
654
#log non fatal errors/warnings
655
flush_log(photo, image_file, action)
657
except Exception, details:
658
flush_log(photo, image_file, action)
659
folder, image = os.path.split(ensure_unicode(image_file))
660
reason = exception_to_unicode(details)
661
message = u'%s\n%s\n%s' % (
662
_("Can not apply action %(a)s on image '%(i)s' in folder:")\
663
% {'a': _(action.label), 'i': image},
667
return process_error(photo, message, image_file, action,
671
def apply_actions_to_photos(actions, settings, paths=None, drop=False,
673
"""Apply all the actions to the photos in path.
675
:param actions: actions
676
:type actions: list of :class:`core.models.Action`
677
:param settings: process settings (writable, eg recursion, ...)
678
:type settings: dictionary
681
paths where the images are located. If they are not specified,
682
Phatch will ask them to the user.
684
:type paths: list of strings
687
True in case files were dropped or phatch is started as a
696
actions = check_actionlist(actions, settings)
700
# Get paths (and update settings) -> show execute dialog
701
paths = get_paths_and_settings(paths, settings, drop=drop)
705
# retrieve all necessary variables in one time
706
vars = set(pil.BASE_VARS).union(get_vars(actions))
707
if settings['check_images_first']:
708
# we need some extra vars for the list control
709
vars = TREE_VARS.union(vars)
710
vars_file, vars_not_file = metadata.InfoFile.split_vars(list(vars))
711
info_file = metadata.InfoFile(vars=list(vars_file))
713
# Check if all files exist
714
# folderindex is set here in filter_image_infos
715
image_infos = get_image_infos(paths, info_file,
716
settings['extensions'], settings['recursive'])
720
# Check if all the images are valid
721
# -> show invalid to user
722
# -> show valid to user in tree dialog (optional)
723
if settings['check_images_first']:
724
image_infos = verify_images(image_infos, settings['repeat'])
729
if not init_actions(actions):
733
skip_existing_images = not (settings['overwrite_existing_images'] or\
734
settings['overwrite_existing_images_forced']) and\
735
not settings['no_save']
737
'stop_for_errors': settings['stop_for_errors'],
741
# only keep static vars
742
vars_not_file = pil.split_vars_static_dynamic(vars_not_file)[0]
744
# create parent info instance
745
# -> will be used by different files with the open method
746
info_not_file = metadata.InfoExtract(vars=vars_not_file)
748
# Execute action list
749
image_amount = len(image_infos)
750
actions_amount = len(actions) + 1 # open image is extra action
752
is_done = actions[-1].is_done # checking method for resuming
753
read_only_settings = ReadOnlyDict(settings)
755
# Start progress dialog
756
repeat = settings['repeat']
757
send.frame_show_progress(title=_("Executing action list"),
758
parent_max=image_amount * repeat,
759
child_max=actions_amount,
760
message=PROGRESS_MESSAGE)
763
for image_index, image_info in enumerate(image_infos):
764
statement = apply_actions_to_photo(actions, image_info, info_not_file,
765
cache, read_only_settings, skip_existing_images, result, report,
766
is_done, image_index, repeat)
768
if statement == 'return':
769
send.progress_close()
771
elif statement == 'break':
775
send.progress_close()
779
# mention amount of photos and duration
780
delta = time.time() - start
781
duration = timedelta(seconds=int(delta))
782
if image_amount == 1:
783
message = _('One image done in %s') % duration
785
message = _('%(amount)d images done in %(duration)s')\
786
% {'amount': image_amount, 'duration': duration}
788
if ERROR_LOG_COUNTER == 1:
789
message += '\n' + _('One issue was logged')
790
elif ERROR_LOG_COUNTER:
791
message += '\n' + _('%d issues were logged')\
793
if WARNING_LOG_COUNTER == 1:
794
message += '\n' + _('One warning was logged')
795
elif WARNING_LOG_COUNTER:
796
message += '\n' + _('%d warnings were logged')\
797
% WARNING_LOG_COUNTER
800
send.frame_show_notification(message, report=report)
803
if ERROR_LOG_COUNTER == 0 and WARNING_LOG_COUNTER == 0:
804
if settings['always_show_status_dialog']:
805
send.frame_show_status(message, log=False)
807
message = '%s\n\n%s' % (message, SEE_LOG)
808
send.frame_show_status(message)
811
def apply_actions_to_photo(actions, image_info, info_not_file,
812
cache, read_only_settings, skip_existing_images, result, report,
813
is_done, image_index, repeat):
814
"""Apply the action list to one photo."""
815
image_info['index'] = image_index
816
#open image and check for errors
817
photo, result = get_photo(image_info, info_not_file, result)
821
elif not photo or result['skip']:
825
info.set('imageindex', image_index)
826
image = photo.get_layer().image
827
for r in range(repeat):
828
info.set('index', image_index * repeat + r)
829
info.set('repeatindex', r)
830
#update image file & progress dialog box
832
send.progress_update_filename(progress_result, info['index'],
834
if progress_result and not progress_result['keepgoing']:
837
#check if already not done
838
if skip_existing_images and is_done(photo):
841
photo.get_layer().image = image
843
photo.get_layer().image = image.copy()
845
for action_index, action in enumerate(actions):
848
send.progress_update_index(progress_result, info['index'],
850
if progress_result and not progress_result['keepgoing']:
854
photo, result = apply_action_to_photo(action, photo,
855
read_only_settings, cache, image_info['path'], result)
860
#skip to next image immediately
862
report.extend(photo.report_files)
872
def import_module(module, folder=None):
873
"""Import a module, mostly used for actions.
875
:param module: module/action name
877
:param folder: folder where the module is situated
881
return __import__(module)
882
return getattr(__import__('%s.%s' % (folder.replace(os.path.sep, '.'),
886
def import_actions():
887
"""Import all actions from the ``ct.PHATCH_ACTIONS_PATH``."""
888
global ACTIONS, ACTION_LABELS, ACTION_FIELDS
890
[import_module(os.path.basename(os.path.splitext(filename)[0]),
891
'actions') for filename in \
892
glob.glob(os.path.join(ct.PHATCH_ACTIONS_PATH, '*.py'))] + \
893
[import_module(os.path.basename(os.path.splitext(filename)[0])) for
894
filename in glob.glob(os.path.join(ct.USER_ACTIONS_PATH, '*.py'))]
896
for module in modules:
898
cl = getattr(module, ct.ACTION)
899
except AttributeError:
902
ACTIONS[cl.label] = cl
904
ACTION_LABELS = ACTIONS.keys()
908
for label in ACTIONS:
909
ACTION_FIELDS[label] = ACTIONS[label]()._fields
912
def save_actionlist(filename, data):
913
"""Save actionlist ``data`` to ``filename``.
917
filename of the actionlist, if it has no extension ``.phatch``
918
will be added automatically.
920
:type filename: string
921
:param data: action list data
922
:type data: dictionary
924
Actionlists are stored as dictionaries::
926
data = {'actions':[...], 'description':'...'}
929
data['version'] = VERSION
931
if os.path.splitext(filename)[1].lower() != ct.EXTENSION:
932
filename += ct.EXTENSION
934
data['actions'] = [action.dump() for action in data['actions']]
936
previous = filename + '~'
937
if os.path.exists(previous):
939
if os.path.isfile(filename):
940
os.rename(filename, previous)
942
f = open(filename, 'wb')
943
f.write(pprint.pformat(data))
947
def open_actionlist(filename):
948
"""Open the action list from a file.
950
:param filename: the filename of the action list
951
:type filename: string
952
:returns: action list
956
f = open(filename, 'rb')
960
data = safe.eval_safe(source)
961
if not data.get('version', '').startswith('0.2'):
962
send.frame_show_error(ERROR_INCOMPATIBLE_ACTIONLIST % ct.INFO)
966
actions = data['actions']
967
for action in actions:
968
actionLabel = action['label']
969
actionFields = action['fields']
970
newAction = ACTIONS[actionLabel]()
971
invalid_labels.extend(['- %s (%s)' % (label, actionLabel)
972
for label in newAction.load(actionFields)])
973
result.append(newAction)
974
warning = assert_safe(result)
975
data['actions'] = result
976
data['invalid labels'] = invalid_labels