forked from eigenholser/jpeg_rename
-
Notifications
You must be signed in to change notification settings - Fork 0
/
jpeg_rename.py
executable file
·323 lines (263 loc) · 10.8 KB
/
jpeg_rename.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
#!/usr/bin/env python
from __future__ import print_function
import argparse
import glob
import os
import re
import stat
import sys
import PIL
from PIL.ExifTags import TAGS
from PIL import Image
# Need to look for *.JPG, *.jpg, and *.jpeg files for consideration.
EXTENSIONS = ['JPG', 'jpg', 'jpeg']
MAX_RENAME_ATTEMPTS = 10
class FileMap():
"""FileMap represents a mapping between the old_fn and the new_fn. It's
methods perform all necessary instance functions for the rename.
Arguments:
str: old_fn - Old Filename
dict: exif_data - For testing only. Dict with sample EXIF data.
"""
def __init__(self, old_fn, avoid_collisions=None, exif_data=None):
"""Initialize FileMap instance.
>>> filemap = FileMap('abc123.jpeg', None, {})
>>> filemap.old_fn
'abc123.jpeg'
>>> filemap.new_fn
'abc123.jpg'
>>>
"""
self.MAX_RENAME_ATTEMPTS = MAX_RENAME_ATTEMPTS
self.old_fn_fq = old_fn
self.workdir = os.path.dirname(old_fn)
self.old_fn = os.path.basename(old_fn)
# Avoid filename collisions (dangerous) or log a message if there
# would be one, and fail the move.
self.collision_detected = False
if avoid_collisions is None:
self.avoid_collisions = False
else:
self.avoid_collisions = avoid_collisions
# Read EXIF data from old filename
if exif_data is None:
self.read_exif_data()
else:
self.exif_data = exif_data
self.get_new_fn()
def read_exif_data(self):
"""Read EXIF data from file. Convert to Python dict."""
# XXX: We already know file exists 'cuz we found it.
img = Image.open(self.old_fn_fq)
info = img._getexif()
self.exif_data ={}
if info is not None:
for tag, value in info.items():
decoded = TAGS.get(tag, tag)
self.exif_data[decoded] = value
else:
raise Exception("{0} has no EXIF data.".format(self.old_fn))
def get_new_fn(self):
"""Generate new filename from old_fn EXIF data if possible. Even if not
possible, lowercase old_fn and normalize file extension.
Arguments:
dict: EXIF data
>>> filemap = FileMap('abc123.jpeg', avoid_collisions=None, exif_data={'DateTimeOriginal': '2014:08:16 06:20:30'})
>>> filemap.new_fn
'20140816_062030.jpg'
"""
# Start with EXIF DateTimeOriginal
try:
new_fn = self.exif_data['DateTimeOriginal']
except KeyError:
new_fn = None
# If this pattern does not strictly match then keep original name.
# YYYY:MM:DD HH:MM:SS
if (new_fn and not
re.match(r'^\d{4}:\d\d:\d\d \d\d:\d\d:\d\d$', new_fn)):
# Setup for next step.
new_fn = None
# Don't assume exif tag exists. If it does not, keep original filename.
# Lowercase filename base and extension
if new_fn is None:
new_fn = self.old_fn.lower()
new_fn = re.sub(r'.jpeg$', r'.jpg', new_fn)
else:
new_fn = "{0}.jpg".format(new_fn)
# XXX: One may argue that the next step should be an 'else' clause of the
# previous 'if' statement. But the intention here is to clean up just a bit
# even if we're not really renaming the file. Windows doesn't like colons
# in filenames.
# Rename using exif DateTimeOriginal
new_fn = re.sub(r':', r'', new_fn)
new_fn = re.sub(r' ', r'_', new_fn)
self.new_fn = new_fn
self.new_fn_fq = os.path.join(self.workdir, new_fn)
def _chmod(self):
"""Removes execute bit from file permission for USR, GRP, and OTH."""
X_ANY = (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
st = os.stat(self.new_fn_fq)
if bool(st.st_mode & X_ANY):
print( "Changing file mode to -rw-r--r-- on {0}.".format(
self.new_fn))
os.chmod(self.new_fn_fq, st.st_mode ^ X_ANY)
def move(self):
"""Move old_fn to new_fn."""
# XXX: This call deliberately placed here instead of __init__(). All
# initialization is performed before any files are moved. The file move
# will change state and may introduce a collision. Doing the uniqueness
# check here will check current state.
try:
self.make_new_fn_unique()
except Exception as e:
raise e
if self.collision_detected:
print( "{0} => {1} Destination collision. Aborting.".format(
self.old_fn, self.new_fn))
#os.path.basename(self.old_fn), os.path.basename(self.new_fn)))
return
try:
print( "Moving the files: {0} ==> {1}".format(
self.old_fn, self.new_fn))
# XXX: Unit tests did not catch this bug.
# os.rename(self.old_fn, self.new_fn)
os.rename(self.old_fn_fq, self.new_fn_fq)
self._chmod()
except OSError as e:
print("Unable to rename file: {0}".format(e.strerror),
file=sys.stderr)
def make_new_fn_unique(self):
"""Check new_fn for uniqueness in 'workdir'. Rename, adding a numerical
suffix until it is unique.
"""
# Rename file by appending number if we have collision.
# TODO: I wish I didn't specify \d+_\d+ for the first part. perhaps not
# -\d\ before .jpg would be better for the second
# match.
new_fn = self.new_fn
counter = 1
while(os.path.exists(self.new_fn_fq)):
if (self.old_fn == self.new_fn):
# Same file, faux collision.
break
if (not self.avoid_collisions):
# Do not attempt to rename.
self.collision_detected = True
break
new_fn = re.sub(r'^(\d+_\d+)-\d+\.jpg',
r'\1-{0}.jpg'.format(counter), self.new_fn)
new_fn = re.sub(r'^(\d+_\d+)\.jpg',
r'\1-{0}.jpg'.format(counter), self.new_fn)
counter += 1
if counter > self.MAX_RENAME_ATTEMPTS:
raise Exception("Too many rename attempts: {0}".format(self.new_fn))
self.new_fn_fq = os.path.join(self.workdir, new_fn)
self.new_fn = new_fn
self.new_fn_fq = os.path.join(self.workdir, new_fn)
class FileMapList():
"""Intelligently add FileMap() instances to file_map list based on order
of instance.new_fn attributes."""
def __init__(self):
self.file_map = []
def add(self, instance):
"""Add, whether insert or append, a FileMap instance to the file_map
list in the order of instance.new_fn. If there are duplicate new_fn
in the list, they will be resolved in instance.move().
"""
index = 0
inserted = False
for fm in self.file_map:
if instance.new_fn < fm.new_fn:
self.file_map.insert(index, instance)
inserted = True
break
index += 1
# Reached end of list with no insert. Append to list instead.
if not inserted:
self.file_map.append(instance)
def get(self):
"""Define a generator function here to return items on the file_map
list."""
return (x for x in self.file_map)
def init_file_map(workdir, avoid_collisions=None):
"""Read the work directory looking for files with extensions defined in the
EXTENSIONS constant. Note that this could use a more elaborate magic
number mechanism that would be cool.
Arguments:
str: workdir - The directory in which all activity will occur.
Returns:
list: file_map - List of FileMap instances.
"""
# List of FileMap objects.
file_map = FileMapList()
# Initialize file_map list.
for extension in EXTENSIONS:
for filename in glob.glob(os.path.join(workdir,
'*.{0}'.format(extension))):
try:
file_map.add(FileMap(filename, avoid_collisions))
except Exception as e:
print("{0}".format(e.message), file=sys.stderr)
return file_map
def process_file_map(file_map, simon_sez=None, move_func=None):
"""Iterate through the Python list of FileMap objects. Move the file if
Simon sez.
Arguments:
str: workdir - Working directory.
dict: file_map - old_fn to new_fn mapping.
boolean: simon_sez - Dry run or real thing.
func: move_func - Move function to use for testing or default.
Returns:
None
>>> filemap = FileMap('IMG0332.JPG', avoid_collisions=None, exif_data={'DateTimeOriginal': '2014-08-18 20:23:83'})
>>> def move_func(old_fn, new_fn): pass
>>> file_map_list = FileMapList()
>>> file_map_list.add(filemap)
>>> process_file_map(file_map_list, True, move_func)
"""
# XXX: Of marginal utility
if simon_sez is None:
simon_sez = False
fm_list = file_map.get()
for fm in fm_list:
try:
if simon_sez:
if move_func is None:
fm.move()
else:
move_func(fm.old_fn, fm.new_fn)
else:
if fm.old_fn != fm.new_fn:
print("DRY RUN: {0} ==> {1}".format(fm.old_fn, fm.new_fn))
fm.same_files = False # For unit test only.
except Exception as e:
print("{0}".format(e.message), file=sys.stderr)
break
def process_all_files(workdir=None, simon_sez=None, avoid_collisions=None):
"""Manage the entire process of gathering data and renaming files."""
if workdir is None:
workdir = os.path.dirname(os.path.abspath(__file__))
if not os.path.exists(workdir):
print("Directory {0} does not exist. Exiting.",format(workdir),
file=sys.stderr)
sys.exit(1)
if not os.access(workdir, os.W_OK):
print("Directory {0} is not writable. Exiting.".format(workdir),
file=sys.stderr)
sys.exit(1)
file_map = init_file_map(workdir, avoid_collisions)
process_file_map(file_map, simon_sez)
def main():
"""Parse command-line arguments. Initiate file processing."""
parser = argparse.ArgumentParser()
parser.add_argument("-s", "--simon-sez",
help="Really, Simon sez rename the files!", action="store_true")
parser.add_argument("-a", "--avoid-collisions",
help="Rename until filenames do not collide. Danger!", action="store_true")
parser.add_argument("-d", "--directory",
help="Read files from this directory.")
myargs = parser.parse_args()
process_all_files(workdir=myargs.directory, simon_sez=myargs.simon_sez,
avoid_collisions=myargs.avoid_collisions)
if __name__ == '__main__': # pragma: no cover
main()