~osomon/phatch/pyexiv2-0.3

« back to all changes in this revision

Viewing changes to phatch/core/api.py

  • Committer: spe.stani.be at gmail
  • Date: 2010-03-13 01:39:24 UTC
  • mfrom: (1542.1.33 context)
  • Revision ID: spe.stani.be@gmail.com-20100313013924-x46mqp2wd5c81dt4
merge with context branch

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
# Phatch - Photo Batch Processor
2
 
# Copyright (C) 2009 www.stani.be
3
 
#
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.
8
 
#
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.
13
 
#
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/
16
 
 
17
 
# Phatch recommends SPE (http://pythonide.stani.be) for editing python files.
18
 
 
19
 
# Follows PEP8
20
 
 
21
 
try:
22
 
    _
23
 
except NameError:
24
 
    __builtins__['_'] = unicode
25
 
 
26
 
#---import modules
27
 
 
28
 
#standard library
29
 
import logging
30
 
import glob
31
 
import operator
32
 
import os
33
 
import pprint
34
 
import string
35
 
import time
36
 
import traceback
37
 
from cStringIO import StringIO
38
 
from datetime import timedelta
39
 
 
40
 
#gui-independent
41
 
from data.version import VERSION
42
 
from lib import formField
43
 
from lib import metadata
44
 
from lib import openImage
45
 
from lib import safe
46
 
from lib.odict import ReadOnlyDict
47
 
from lib.unicoding import ensure_unicode, exception_to_unicode, ENCODING
48
 
 
49
 
import ct
50
 
import pil
51
 
from message import send
52
 
 
53
 
#---constants
54
 
PROGRESS_MESSAGE = 'In: %s%s\nFile' % (' ' * 100, '.')
55
 
ERROR_MESSAGE = """%(number)s: %(message)s
56
 
Image: %(input)s
57
 
Action:
58
 
%(action)s
59
 
Details:
60
 
%(details)s
61
 
"""
62
 
WARN_MESSAGE = """%(number)s: %(message)s
63
 
Image: %(input)s
64
 
Action:
65
 
%(action)s
66
 
"""
67
 
SEE_LOG = _('See "%s" for more details.') % _('Show Log')
68
 
TREE_HEADERS = ['filename', 'type', 'folder', 'subfolder', 'root',
69
 
    'foldername']
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.')
74
 
 
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.")
81
 
 
82
 
#---classes
83
 
 
84
 
 
85
 
class PathError(Exception):
86
 
 
87
 
    def __init__(self, filename):
88
 
        """PathError for invalid path.
89
 
 
90
 
        :param filename: filename of the invalid path
91
 
        :type filename:string
92
 
        """
93
 
        self.filename = filename
94
 
 
95
 
    def __str__(self):
96
 
        return _('"%s" is not a valid path.') % self.filename
97
 
 
98
 
#---init/exit
99
 
 
100
 
 
101
 
def init():
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()
106
 
    import_actions()
107
 
 
108
 
 
109
 
#---error logs
110
 
 
111
 
 
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,
119
 
        encoding=ENCODING,
120
 
        mode='wb',
121
 
    )
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)
127
 
 
128
 
 
129
 
def log_warning(message, input, action=None):
130
 
    """Writer warning message to log file.
131
 
 
132
 
    Helper function for :func:`flush_log`, :func:`process_error`.
133
 
 
134
 
    :param message: error message
135
 
    :type message: string
136
 
    :param input: input image
137
 
    :type input: string
138
 
    :returns: error log details
139
 
    :rtype: string
140
 
    """
141
 
    global WARNING_LOG_COUNTER
142
 
    if action:
143
 
        action = pprint.pformat(action.dump())
144
 
    details = WARN_MESSAGE % {
145
 
        'number': WARNING_LOG_COUNTER + 1,
146
 
        'message': message,
147
 
        'input': input,
148
 
        'action': action,
149
 
    }
150
 
    logging.warn(details)
151
 
    WARNING_LOG_COUNTER += 1
152
 
    return details
153
 
 
154
 
 
155
 
def log_error(message, input, action=None):
156
 
    """Writer error message to log file.
157
 
 
158
 
    Helper function for :func:`flush_log`, :func:`process_error`.
159
 
 
160
 
    :param message: error message
161
 
    :type message: string
162
 
    :param input: input image
163
 
    :type input: string
164
 
    :returns: error log details
165
 
    :rtype: string
166
 
    """
167
 
    global ERROR_LOG_COUNTER
168
 
    if action:
169
 
        action = pprint.pformat(action.dump())
170
 
    details = ERROR_MESSAGE % {
171
 
        'number': ERROR_LOG_COUNTER + 1,
172
 
        'message': message,
173
 
        'input': input,
174
 
        'action': action,
175
 
        'details': traceback.format_exc(),
176
 
    }
177
 
    logging.error(details)
178
 
    ERROR_LOG_COUNTER += 1
179
 
    return details
180
 
 
181
 
#---collect vars
182
 
 
183
 
 
184
 
def get_vars(actions):
185
 
    """Extract all used variables from actions.
186
 
 
187
 
    :param actions: list of actions
188
 
    :type actions: list of dict
189
 
    """
190
 
    vars = []
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())
195
 
    return vars
196
 
 
197
 
 
198
 
def assert_safe(actions):
199
 
    test_info = metadata.InfoTest()
200
 
    geek = False
201
 
    warning = ''
202
 
    for action in actions:
203
 
        warning_action = ''
204
 
        if action.label == 'Geek':
205
 
            geek = True
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):
211
 
                continue
212
 
            try:
213
 
                field.assert_safe(label, test_info)
214
 
            except Exception, details:
215
 
                warning_action += '  %s: %s\n'\
216
 
                    % (label, exception_to_unicode(details))
217
 
        if warning_action:
218
 
            warning += '%s %s:\n%s' % (_(action.label), _('Action'),
219
 
                warning_action)
220
 
    if warning:
221
 
        warning += '\n'
222
 
    if geek:
223
 
        warning += '%s\n' % (_('Geek actions are not allowed in safe mode.'))
224
 
    return warning
225
 
 
226
 
#---collect image files
227
 
 
228
 
 
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`::
233
 
 
234
 
        {'day': 14,
235
 
         'filename': 'beach',
236
 
         'filesize': 9682,
237
 
         'folder': u'/home/stani',
238
 
         'foldername': u'stani',
239
 
         'hour': 23,
240
 
         'minute': 43,
241
 
         'month': 3,
242
 
         'monthname': 'March',
243
 
         'path': '/home/stani/beach.jpg',
244
 
         'root': '/home',
245
 
         'second': 26,
246
 
         'subfolder': u'',
247
 
         'type': 'jpg',
248
 
         'weekday': 4,
249
 
         'weekdayname': 'Friday',
250
 
         'year': 2008,
251
 
         '$': 0}
252
 
 
253
 
    ``$`` is the index of the file within a folder.
254
 
 
255
 
    Helper function for :func:`get_image_infos_from_folder`
256
 
 
257
 
    :param folder: folder path (recursion dependent)
258
 
    :type folder: string
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)
264
 
    :type root: string
265
 
    :returns: list of image file info
266
 
    :rtype: list of dictionaries
267
 
    """
268
 
    #check if extensions work ok! '.png' vs 'png'
269
 
    files.sort(key=string.lower)
270
 
    infos = []
271
 
    folder_index = 0
272
 
    for file in files:
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
276
 
            infos.append(info)
277
 
            folder_index += 1
278
 
    return infos
279
 
 
280
 
 
281
 
def get_image_infos_from_folder(folder, info_file, extensions, recursive):
282
 
    """Get all image info dictionaries from a specific folder.
283
 
 
284
 
    :param folder: top folder path
285
 
    :type folder: string
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
292
 
 
293
 
    Helper function for :func:`get_image_infos`
294
 
 
295
 
    .. see also:: :func:`filter_image_infos`
296
 
    """
297
 
    source_parent = folder  # do not change (independent of recursion!)
298
 
    # root = os.path.dirname(folder) #do not change (independent of recursion!)
299
 
    if recursive:
300
 
        image_infos = []
301
 
        for folder, dirs, files in os.walk(folder):
302
 
            image_infos.extend(filter_image_infos(folder, extensions,
303
 
                files, source_parent, info_file))
304
 
        return image_infos
305
 
    else:
306
 
        return filter_image_infos(folder, extensions, os.listdir(folder),
307
 
            source_parent, info_file)
308
 
 
309
 
 
310
 
def get_image_infos(paths, info_file, extensions, recursive):
311
 
    """Get all image info dictionaries from a mix of folder and file paths.
312
 
 
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
321
 
 
322
 
    .. see also:: :func:`get_image_infos_from_folder`
323
 
    """
324
 
    image_infos = []
325
 
    for path in paths:
326
 
        path = os.path.abspath(path.strip())
327
 
        if os.path.isfile(path):
328
 
            #single image file
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))
336
 
        else:
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))
340
 
            return []
341
 
    image_infos.sort(key=operator.itemgetter('path'))
342
 
    return image_infos
343
 
 
344
 
#---check
345
 
 
346
 
 
347
 
def check_actionlist_file_only(actions):
348
 
    """Check whether the action list only exist of file operations
349
 
    (such as copy, rename, ...)
350
 
 
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
354
 
    :rtype: bool
355
 
 
356
 
    >>> from actions import canvas, rename
357
 
    >>> check_actionlist_file_only([canvas.Action()])
358
 
    False
359
 
    >>> check_actionlist_file_only([rename.Action()])
360
 
    True
361
 
    """
362
 
    for action in actions:
363
 
        if not ('file' in action.tags):
364
 
            return False
365
 
    return True
366
 
 
367
 
 
368
 
def check_actionlist(actions, settings):
369
 
    """Verifies action list before executing. It checks whether:
370
 
 
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
375
 
 
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
380
 
 
381
 
    >>> settings = {'no_save':False}
382
 
    >>> check_actionlist([], settings) is None
383
 
    True
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
389
 
    False
390
 
    >>> check_actionlist([canvas_action], settings) is None
391
 
    True
392
 
    >>> settings = {'no_save':True}
393
 
    >>> check_actionlist([canvas_action], settings) is None
394
 
    False
395
 
    >>> settings['overwrite_existing_images_forced']
396
 
    False
397
 
 
398
 
    .. see also:: :func:`check_actionlist_file_only`
399
 
    """
400
 
    #Check if there is something to do
401
 
    if actions == []:
402
 
        send.frame_show_error('%s %s' % (_('Nothing to do.'),
403
 
            _('The action list is empty.')))
404
 
        return None
405
 
    #Check if the actionlist is safe
406
 
    if formField.get_safe():
407
 
        warnings = assert_safe(actions)
408
 
        if warnings:
409
 
            send.frame_show_error('%s\n\n%s\n%s' % (
410
 
                ERROR_UNSAFE_ACTIONLIST_INTRO, warnings,
411
 
                ERROR_UNSAFE_ACTIONLIST_DISABLE_SAFE))
412
 
            return None
413
 
    #Skip disabled actions
414
 
    actions = [action for action in actions if action.is_enabled()]
415
 
    if actions == []:
416
 
        send.frame_show_error('%s %s' % (_('Nothing to do.'),
417
 
            _('There is no action enabled.')))
418
 
        return None
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)
424
 
        return None
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()
429
 
    return actions
430
 
 
431
 
 
432
 
def verify_images(image_infos, repeat):
433
 
    """Filter invalid images out.
434
 
 
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.
438
 
 
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
442
 
    """
443
 
    #show dialog
444
 
    send.frame_show_progress(title=_("Checking images"),
445
 
        parent_max=len(image_infos),
446
 
        message=PROGRESS_MESSAGE)
447
 
    #verify files
448
 
    valid = []
449
 
    invalid = []
450
 
    for index, image_info in enumerate(image_infos):
451
 
        result = {}
452
 
        send.progress_update_filename(result, index, image_info['path'])
453
 
        if not result['keepgoing']:
454
 
            return
455
 
        openImage.verify_image(image_info, valid, invalid)
456
 
    send.progress_close()
457
 
    #show invalid files to the user
458
 
    if invalid:
459
 
        result = {}
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')),
463
 
            files=invalid)
464
 
        if result['cancel']:
465
 
            return
466
 
    #Display an error when no files are left
467
 
    if not valid:
468
 
        send.frame_show_error(_("Sorry, no valid files found"))
469
 
        return
470
 
    #number valid items
471
 
    for index, image_info in enumerate(valid):
472
 
        image_info['index'] = index * repeat
473
 
    #show valid images to the user in tree structure
474
 
    result = {}
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)
479
 
    if result['answer']:
480
 
        return valid
481
 
 
482
 
#---get
483
 
 
484
 
 
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.
488
 
 
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
493
 
    :param drop:
494
 
 
495
 
        True in case files were dropped or phatch is started as a
496
 
        droplet.
497
 
 
498
 
    :type drop: bool
499
 
    """
500
 
    if drop or (paths is None):
501
 
        result = {}
502
 
        send.frame_show_execute_dialog(result, settings, paths)
503
 
        if result['cancel']:
504
 
            return
505
 
        paths = settings['paths']
506
 
        if not paths:
507
 
            send.frame_show_error(_('No files or folder selected.'))
508
 
            return None
509
 
    return paths
510
 
 
511
 
 
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.
515
 
 
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
520
 
    :param result:
521
 
 
522
 
        settings to send to progress dialog box
523
 
        (such as ``stop for errors``)
524
 
 
525
 
    :type result: dict
526
 
    :returns: photo, result
527
 
    :rtype: tuple
528
 
    """
529
 
    try:
530
 
        photo = pil.Photo(info_file, info_not_file)
531
 
        result['skip'] = False
532
 
        result['abort'] = False
533
 
        return photo, result
534
 
    except Exception, details:
535
 
        reason = exception_to_unicode(details)
536
 
        #log error details
537
 
        message = u'%s: %s:\n%s' % (_('Unable to open file'),
538
 
            info_file['path'], reason)
539
 
    ignore = False
540
 
    action = None
541
 
    photo = None
542
 
    return process_error(photo, message, info_file['path'], action,
543
 
            result, ignore)
544
 
 
545
 
#---apply
546
 
 
547
 
 
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.
551
 
 
552
 
    Helper function for :func:`get_photo` and `apply_action`.
553
 
 
554
 
    :param photo: photo
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
563
 
    :rtype: tuple
564
 
    """
565
 
    log_error(message, image_file, action)
566
 
    #show error dialog
567
 
    if result['stop_for_errors']:
568
 
        send.frame_show_progress_error(result, message, ignore=ignore)
569
 
        #if result:
570
 
        answer = result['answer']
571
 
        if answer == _('abort'):
572
 
            #send.progress_close()
573
 
            result['skip'] = False
574
 
            result['abort'] = True
575
 
            return photo, result
576
 
        result['last_answer'] = answer
577
 
        if answer == _('skip'):
578
 
            result['skip'] = True
579
 
            result['abort'] = False
580
 
            return photo, result
581
 
    elif result['last_answer'] == _('skip'):
582
 
        result['skip'] = True
583
 
        result['abort'] = False
584
 
        return photo, result
585
 
    result['skip'] = False
586
 
    result['abort'] = False
587
 
    return photo, result
588
 
 
589
 
 
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
593
 
    file.
594
 
 
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`
601
 
    """
602
 
    log = photo.get_log()
603
 
    if log:
604
 
        log_warning(log, image_file, action)
605
 
        photo.clear_log()
606
 
 
607
 
 
608
 
def init_actions(actions):
609
 
    """Initializes all actions. Shows an error to the user if an
610
 
    action fails to initialize.
611
 
 
612
 
    :param actions: actions
613
 
    :type actions: list of :class:`core.models.Action`
614
 
    :returns: False, if one action fails, True otherwise
615
 
    :rtype: bool
616
 
    """
617
 
    for action in actions:
618
 
        try:
619
 
            action.init()
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)
626
 
            return False
627
 
    return True
628
 
 
629
 
 
630
 
def apply_action_to_photo(action, photo, read_only_settings, cache,
631
 
        image_file, result):
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
635
 
    change them.
636
 
 
637
 
    :param action: action
638
 
    :type action: :class:`core.models.Action`
639
 
    :param photo: photo
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
649
 
    """
650
 
    try:
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)
656
 
        return photo, result
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},
664
 
            folder,
665
 
            reason,
666
 
        )
667
 
        return process_error(photo, message, image_file, action,
668
 
            result, ignore=True)
669
 
 
670
 
 
671
 
def apply_actions_to_photos(actions, settings, paths=None, drop=False,
672
 
        update=None):
673
 
    """Apply all the actions to the photos in path.
674
 
 
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
679
 
    :param paths:
680
 
 
681
 
        paths where the images are located. If they are not specified,
682
 
        Phatch will ask them to the user.
683
 
 
684
 
    :type paths: list of strings
685
 
    :param drop:
686
 
 
687
 
        True in case files were dropped or phatch is started as a
688
 
        droplet.
689
 
 
690
 
    :type drop: bool
691
 
    """
692
 
    # Start log file
693
 
    _init_log_file()
694
 
 
695
 
    # Check action list
696
 
    actions = check_actionlist(actions, settings)
697
 
    if not actions:
698
 
        return
699
 
 
700
 
    # Get paths (and update settings) -> show execute dialog
701
 
    paths = get_paths_and_settings(paths, settings, drop=drop)
702
 
    if not paths:
703
 
        return
704
 
 
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))
712
 
 
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'])
717
 
    if not image_infos:
718
 
        return
719
 
 
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'])
725
 
        if not image_infos:
726
 
            return
727
 
 
728
 
    # Initialize actions
729
 
    if not init_actions(actions):
730
 
        return
731
 
 
732
 
    # Retrieve settings
733
 
    skip_existing_images = not (settings['overwrite_existing_images'] or\
734
 
        settings['overwrite_existing_images_forced']) and\
735
 
        not settings['no_save']
736
 
    result = {
737
 
        'stop_for_errors': settings['stop_for_errors'],
738
 
        'last_answer': None,
739
 
    }
740
 
 
741
 
    # only keep static vars
742
 
    vars_not_file = pil.split_vars_static_dynamic(vars_not_file)[0]
743
 
 
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)
747
 
 
748
 
    # Execute action list
749
 
    image_amount = len(image_infos)
750
 
    actions_amount = len(actions) + 1  # open image is extra action
751
 
    cache = {}
752
 
    is_done = actions[-1].is_done  # checking method for resuming
753
 
    read_only_settings = ReadOnlyDict(settings)
754
 
 
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)
761
 
    report = []
762
 
    start = time.time()
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)
767
 
        # reraise statement
768
 
        if statement == 'return':
769
 
            send.progress_close()
770
 
            return
771
 
        elif statement == 'break':
772
 
            break
773
 
        if update:
774
 
            update()
775
 
    send.progress_close()
776
 
    if update:
777
 
        update()
778
 
 
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
784
 
    else:
785
 
        message = _('%(amount)d images done in %(duration)s')\
786
 
            % {'amount': image_amount, 'duration': duration}
787
 
    # add error status
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')\
792
 
            % ERROR_LOG_COUNTER
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
798
 
 
799
 
    # show notification
800
 
    send.frame_show_notification(message, report=report)
801
 
 
802
 
    # show status dialog
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)
806
 
    else:
807
 
        message = '%s\n\n%s' % (message, SEE_LOG)
808
 
        send.frame_show_status(message)
809
 
 
810
 
 
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)
818
 
    if result['abort']:
819
 
        photo.close()
820
 
        return 'return'
821
 
    elif not photo or result['skip']:
822
 
        photo.close()
823
 
        return 'continue'
824
 
    info = photo.info
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
831
 
        progress_result = {}
832
 
        send.progress_update_filename(progress_result, info['index'],
833
 
            info['path'])
834
 
        if progress_result and not progress_result['keepgoing']:
835
 
            photo.close()
836
 
            return 'return'
837
 
        #check if already not done
838
 
        if skip_existing_images and is_done(photo):
839
 
            continue
840
 
        if r == repeat - 1:
841
 
            photo.get_layer().image = image
842
 
        elif r > 0:
843
 
            photo.get_layer().image = image.copy()
844
 
        #do the actions
845
 
        for action_index, action in enumerate(actions):
846
 
            #update progress
847
 
            progress_result = {}
848
 
            send.progress_update_index(progress_result, info['index'],
849
 
                action_index)
850
 
            if progress_result and not progress_result['keepgoing']:
851
 
                photo.close()
852
 
                return 'return'
853
 
            #apply action
854
 
            photo, result = apply_action_to_photo(action, photo,
855
 
                read_only_settings, cache, image_info['path'], result)
856
 
            if result['abort']:
857
 
                photo.close()
858
 
                return 'return'
859
 
            elif result['skip']:
860
 
                #skip to next image immediately
861
 
                continue
862
 
    report.extend(photo.report_files)
863
 
    photo.close()
864
 
    if result['abort']:
865
 
        return 'return'
866
 
 
867
 
 
868
 
#---common
869
 
 
870
 
#---classes
871
 
 
872
 
def import_module(module, folder=None):
873
 
    """Import a module, mostly used for actions.
874
 
 
875
 
    :param module: module/action name
876
 
    :type module: string
877
 
    :param folder: folder where the module is situated
878
 
    :type folder: string
879
 
    """
880
 
    if folder is None:
881
 
        return __import__(module)
882
 
    return getattr(__import__('%s.%s' % (folder.replace(os.path.sep, '.'),
883
 
        module)), module)
884
 
 
885
 
 
886
 
def import_actions():
887
 
    """Import all actions from the ``ct.PHATCH_ACTIONS_PATH``."""
888
 
    global ACTIONS, ACTION_LABELS, ACTION_FIELDS
889
 
    modules = \
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'))]
895
 
    ACTIONS = {}
896
 
    for module in modules:
897
 
        try:
898
 
            cl = getattr(module, ct.ACTION)
899
 
        except AttributeError:
900
 
            continue
901
 
        #register action
902
 
        ACTIONS[cl.label] = cl
903
 
    #ACTION_LABELS
904
 
    ACTION_LABELS = ACTIONS.keys()
905
 
    ACTION_LABELS.sort()
906
 
    #ACTION_FIELDS
907
 
    ACTION_FIELDS = {}
908
 
    for label in ACTIONS:
909
 
        ACTION_FIELDS[label] = ACTIONS[label]()._fields
910
 
 
911
 
 
912
 
def save_actionlist(filename, data):
913
 
    """Save actionlist ``data`` to ``filename``.
914
 
 
915
 
    :param filename:
916
 
 
917
 
        filename of the actionlist, if it has no extension ``.phatch``
918
 
        will be added automatically.
919
 
 
920
 
    :type filename: string
921
 
    :param data: action list data
922
 
    :type data: dictionary
923
 
 
924
 
    Actionlists are stored as dictionaries::
925
 
 
926
 
        data = {'actions':[...], 'description':'...'}
927
 
    """
928
 
    #add version number
929
 
    data['version'] = VERSION
930
 
    #check filename
931
 
    if os.path.splitext(filename)[1].lower() != ct.EXTENSION:
932
 
        filename += ct.EXTENSION
933
 
    #prepare data
934
 
    data['actions'] = [action.dump() for action in data['actions']]
935
 
    #backup previous
936
 
    previous = filename + '~'
937
 
    if os.path.exists(previous):
938
 
        os.remove(previous)
939
 
    if os.path.isfile(filename):
940
 
        os.rename(filename, previous)
941
 
    #write it
942
 
    f = open(filename, 'wb')
943
 
    f.write(pprint.pformat(data))
944
 
    f.close()
945
 
 
946
 
 
947
 
def open_actionlist(filename):
948
 
    """Open the action list from a file.
949
 
 
950
 
    :param filename: the filename of the action list
951
 
    :type filename: string
952
 
    :returns: action list
953
 
    :rtype: dictionary
954
 
    """
955
 
    #read source
956
 
    f = open(filename, 'rb')
957
 
    source = f.read()
958
 
    f.close()
959
 
    #load data
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)
963
 
        return None
964
 
    result = []
965
 
    invalid_labels = []
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
977
 
    return data, warning