skilleter-thingy 0.1.18__py3-none-any.whl → 0.1.21__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.

Potentially problematic release.


This version of skilleter-thingy might be problematic. Click here for more details.

Files changed (33) hide show
  1. skilleter_thingy/ffind.py +2 -2
  2. skilleter_thingy/ggit.py +1 -1
  3. skilleter_thingy/ggrep.py +2 -1
  4. skilleter_thingy/git_br.py +3 -3
  5. skilleter_thingy/git_common.py +2 -2
  6. skilleter_thingy/git_hold.py +6 -6
  7. skilleter_thingy/git_parent.py +4 -3
  8. skilleter_thingy/git_review.py +16 -12
  9. skilleter_thingy/git_update.py +2 -2
  10. skilleter_thingy/git_wt.py +5 -5
  11. skilleter_thingy/gitcmp_helper.py +6 -1
  12. skilleter_thingy/gphotosync.py +12 -8
  13. skilleter_thingy/localphotosync.py +77 -358
  14. skilleter_thingy/multigit.py +30 -34
  15. skilleter_thingy/photodupe.py +10 -10
  16. skilleter_thingy/py_audit.py +4 -2
  17. skilleter_thingy/readable.py +13 -11
  18. skilleter_thingy/sysmon.py +2 -2
  19. skilleter_thingy/thingy/colour.py +6 -2
  20. skilleter_thingy/thingy/dircolors.py +20 -18
  21. skilleter_thingy/thingy/git2.py +0 -1
  22. skilleter_thingy/thingy/popup.py +1 -1
  23. skilleter_thingy/thingy/tfm_pane.py +3 -3
  24. skilleter_thingy/thingy/tidy.py +14 -13
  25. skilleter_thingy/trimpath.py +5 -4
  26. skilleter_thingy/venv_create.py +1 -1
  27. {skilleter_thingy-0.1.18.dist-info → skilleter_thingy-0.1.21.dist-info}/METADATA +1 -1
  28. skilleter_thingy-0.1.21.dist-info/PKG-INFO 2 +193 -0
  29. {skilleter_thingy-0.1.18.dist-info → skilleter_thingy-0.1.21.dist-info}/RECORD +33 -32
  30. {skilleter_thingy-0.1.18.dist-info → skilleter_thingy-0.1.21.dist-info}/WHEEL +0 -0
  31. {skilleter_thingy-0.1.18.dist-info → skilleter_thingy-0.1.21.dist-info}/entry_points.txt +0 -0
  32. {skilleter_thingy-0.1.18.dist-info → skilleter_thingy-0.1.21.dist-info}/licenses/LICENSE +0 -0
  33. {skilleter_thingy-0.1.18.dist-info → skilleter_thingy-0.1.21.dist-info}/top_level.txt +0 -0
@@ -4,24 +4,19 @@
4
4
  Sync a directory tree full of photos into a tree organised by year, month and date
5
5
  """
6
6
 
7
+ # TODO: Ignore patterns for source and destination file paths (.trashed* and .stversions)
8
+ # TODO: Use inotify to detect changes and run continuously
9
+
7
10
  import os
11
+ import glob
12
+ import shutil
8
13
  import sys
9
- import datetime
10
14
  import logging
11
15
  import argparse
12
- import glob
13
16
  import re
14
- import shutil
15
- import PIL
16
- import imagehash
17
17
 
18
- from collections import defaultdict
19
18
  from enum import Enum
20
19
 
21
- from PIL import Image, ExifTags
22
-
23
- import thingy.colour as colour
24
-
25
20
  ################################################################################
26
21
 
27
22
  # Default locations for local storage of photos and videos
@@ -31,51 +26,25 @@ DEFAULT_VIDEO_DIR = os.path.expanduser('~/Videos')
31
26
 
32
27
  # File extensions (case-insensitive)
33
28
 
34
- IMAGE_EXTENSIONS = ('.jpg', '.jpeg', '.png', )
35
- VIDEO_EXTENSIONS = ('.mp4', '.mov', )
36
- IGNORE_EXTENSIONS = ('.ini', )
29
+ IMAGE_EXTENSIONS = ('.jpg', '.jpeg', '.png')
30
+ VIDEO_EXTENSIONS = ('.mp4', '.mov')
37
31
 
38
32
  # Enum of filetypes
39
33
 
40
34
  class FileType(Enum):
35
+ """File types"""
41
36
  IMAGE = 0
42
37
  VIDEO = 1
43
38
  UNKNOWN = 2
44
39
  IGNORE = 3
45
40
 
46
- # Regexes for matching date strings
47
-
48
- YYYY_MM_DD_re = re.compile(r'^(\d{4}):(\d{2}):(\d{2})')
49
- IMG_DATE_re = re.compile(r'(?:IMG|VID)[-_](\d{4})(\d{2})(\d{2})[-_.].*')
50
-
51
- GENERAL_DATE_re = re.compile(r'(\d{4})[-_ ](\d{2})[-_ ](\d{2})')
52
-
53
- YEAR_MONTH_PATH_re = re.compile(r'/(\d{4})/(\d{2})/')
54
-
55
- YYYY_MM_re = re.compile(r'(\d{4})-(\d{2})')
56
-
57
- DUP_RE = re.compile(r'(.*) \{aalq_f.*\}(.*)')
58
-
59
- # Date format for YYYY-MM
60
-
61
- DATE_FORMAT = '%Y-%m'
62
-
63
- # If two pictures with the same name prefix have a hash differing by less than
64
- # this then we don't hash the duplicates
65
-
66
- MIN_HASH_DIFF = 15
67
-
68
41
  ################################################################################
69
42
 
70
- def parse_yyyymm(datestr):
71
- """Convert a date string in the form YYYY-MM to a datetime.date"""
72
-
73
- date_match = YYYY_MM_re.fullmatch(datestr)
43
+ def error(msg, status=1):
44
+ """Exit with an error message"""
74
45
 
75
- if not date_match:
76
- colour.error(f'ERROR: Invalid date: {datestr}')
77
-
78
- return datetime.date(int(date_match.group(1)), int(date_match.group(2)), day=1)
46
+ print(msg)
47
+ sys.exit(status)
79
48
 
80
49
  ################################################################################
81
50
 
@@ -84,20 +53,18 @@ def parse_command_line():
84
53
 
85
54
  parser = argparse.ArgumentParser(description='Sync photos from Google Photos')
86
55
 
87
- today = datetime.date.today()
88
-
89
56
  parser.add_argument('--verbose', '-v', action='store_true', help='Output verbose status information')
90
57
  parser.add_argument('--dryrun', '--dry-run', '-D', action='store_true', help='Just list files to be copied, without actually copying them')
91
- parser.add_argument('--picturedir', '-P', action='store', default=DEFAULT_PHOTO_DIR, help=f'Location of local picture storage directory (defaults to {DEFAULT_PHOTO_DIR})')
92
- parser.add_argument('--videodir', '-V', action='store', default=DEFAULT_VIDEO_DIR, help=f'Location of local video storage directory (defaults to {DEFAULT_VIDEO_DIR})')
93
- parser.add_argument('--skip-no-day', '-z', action='store_true', help='Don\'t sync files where the day of the month could not be determined')
58
+ parser.add_argument('--picturedir', '-P', action='store', default=DEFAULT_PHOTO_DIR,
59
+ help=f'Location of local picture storage directory (defaults to {DEFAULT_PHOTO_DIR})')
60
+ parser.add_argument('--videodir', '-V', action='store', default=DEFAULT_VIDEO_DIR,
61
+ help=f'Location of local video storage directory (defaults to {DEFAULT_VIDEO_DIR})')
94
62
  parser.add_argument('--path', '-p', action='store', default=None, help='Path to sync from')
95
- parser.add_argument('action', nargs='*', help='Actions to perform (report or sync)')
96
63
 
97
64
  args = parser.parse_args()
98
65
 
99
66
  if not args.path:
100
- colour.error('You must specify a source directory')
67
+ error('You must specify a source directory')
101
68
 
102
69
  # Configure debugging
103
70
 
@@ -110,38 +77,10 @@ def parse_command_line():
110
77
  logging.debug('Videos: %s', args.videodir)
111
78
  logging.debug('Dry run: %d', args.dryrun)
112
79
 
113
- args.local_dir = {'photo': args.picturedir, 'video': args.videodir}
114
-
115
80
  return args
116
81
 
117
82
  ################################################################################
118
83
 
119
- def get_exif_data(image):
120
- """Return EXIF data for the image as a dictionary"""
121
-
122
- try:
123
- img = Image.open(image)
124
-
125
- img_exif = img.getexif()
126
- except OSError as exc:
127
- logging.info('Error reading EXIF data for %s - %s', image, exc)
128
- img_exif = None
129
-
130
- result = {}
131
-
132
- if img_exif is None:
133
- return result
134
-
135
- for key, val in img_exif.items():
136
- if key in ExifTags.TAGS:
137
- result[ExifTags.TAGS[key]] = val
138
- else:
139
- result[key] = val
140
-
141
- return result
142
-
143
- ################################################################################
144
-
145
84
  def get_filetype(filename):
146
85
  """Return the type of a file"""
147
86
 
@@ -155,313 +94,93 @@ def get_filetype(filename):
155
94
  if ext in VIDEO_EXTENSIONS:
156
95
  return FileType.VIDEO
157
96
 
158
- if ext in IGNORE_EXTENSIONS:
159
- return FileType.IGNORE
160
-
161
97
  return FileType.UNKNOWN
162
98
 
163
99
  ################################################################################
164
100
 
165
- def find_files(directory_wildcards):
166
- """Return a list of all the files in the specified directory tree, which can contain wildcards,
167
- as 3 lists; pictures, videos and unknown."""
168
-
169
- image_list = {}
170
- video_list = {}
171
- unknown_list = []
172
-
173
- logging.info('Reading files in the directory tree(s) at %s', ', '.join(directory_wildcards))
174
-
175
- for directory_wildcard in directory_wildcards:
176
- directories = glob.glob(directory_wildcard)
177
-
178
- for directory in directories:
179
- for root, _, files in os.walk(directory):
180
- logging.debug('Reading %s', root)
181
-
182
- for file in files:
183
- filepath = os.path.join(root, file)
184
-
185
- file_type = get_filetype(filepath)
186
-
187
- if file_type == FileType.IMAGE:
188
- try:
189
- exif = get_exif_data(filepath)
190
-
191
- image_list[filepath] = exif
192
- except PIL.UnidentifiedImageError:
193
- colour.write(f'[BOLD:WARNING:] Unable to get EXIF data from [BLUE:{filepath}]')
194
- image_list[filepath] = {}
195
-
196
- elif file_type == FileType.VIDEO:
197
- # TODO: Is there a way of getting EXIF-type data from video files? (https://thepythoncode.com/article/extract-media-metadata-in-python but does it include date info?)
198
- video_list[filepath] = {}
199
-
200
- elif file_type == FileType.UNKNOWN:
201
- unknown_list.append(filepath)
202
-
203
- logging.info('Read %s image files', len(image_list))
204
- logging.info('Read %s video files', len(video_list))
205
- logging.info('Read %s unknown files', len(unknown_list))
206
-
207
- return image_list, video_list, unknown_list
208
-
209
- ################################################################################
210
-
211
- def get_media_date(name, info):
212
- """Try and determine the date for a given picture. Returns y, m, d or
213
- None, None, None"""
214
-
215
- # If the EXIF data has the date & time, just return that
216
-
217
- if 'DateTimeOriginal' in info:
218
- original_date_time = info['DateTimeOriginal']
219
-
220
- date_match = YYYY_MM_DD_re.match(original_date_time)
221
- if date_match:
222
- year = date_match.group(1)
223
- month = date_match.group(2)
224
- day = date_match.group(3)
225
-
226
- return year, month, day
227
-
228
- # No EXIF date and time, try and parse it out of the filename
229
-
230
- picture_name = os.path.basename(name)
231
-
232
- date_match = IMG_DATE_re.match(picture_name) or GENERAL_DATE_re.search(picture_name)
233
-
234
- if date_match:
235
- year = date_match.group(1)
236
- month = date_match.group(2)
237
- day = date_match.group(3)
238
-
239
- return year, month, day
240
-
241
- date_match = YEAR_MONTH_PATH_re.search(name)
242
- if date_match:
243
- year = date_match.group(1)
244
- month = date_match.group(2)
245
- day = '00'
246
-
247
- return year, month, day
248
-
249
- # A miserable failure
250
-
251
- return None, None, None
252
-
253
- ################################################################################
101
+ def media_sync(args):
102
+ """Sync photos and videos from args.path to date-structured directory
103
+ trees in args.picturedir and args.videodir.
104
+ Assumes that the source files are in Android naming format:
105
+ (IMG|VID)_YYYYMMDD_*.(jpg|mp4)
106
+ Looks for a destination directory called:
107
+ YYYY/YYYY-MM-DD*/
108
+ If multiple destination directories exist, it uses the first one when the
109
+ names are sorted alphbetically
110
+ If a file with the same name exists in the destination directory it is
111
+ not overwritten"""
112
+
113
+ files_copied = 0
114
+
115
+ filetype_re = re.compile(r'(PANO|IMG|VID)[-_](\d{4})(\d{2})(\d{2})[-_.].*')
116
+
117
+ for sourcefile in [source for source in glob.glob(os.path.join(args.path, '*')) if os.path.isfile(source)]:
118
+ filetype = get_filetype(sourcefile)
119
+
120
+ if filetype == FileType.IMAGE:
121
+ dest_dir = args.picturedir
122
+ elif filetype == FileType.VIDEO:
123
+ dest_dir = args.videodir
124
+ else:
125
+ logging.info('Ignoring %s - unable to determine file type', sourcefile)
126
+ continue
254
127
 
255
- def sync_media_local(dryrun, skip_no_day, media_files, destination_dir):
256
- """Sync files from the cache to local storage"""
128
+ date_match = filetype_re.fullmatch(os.path.basename(sourcefile))
129
+ if not date_match:
130
+ logging.debug('Ignoring %s - unable to extract date from filename', sourcefile)
131
+ continue
257
132
 
258
- # Iterate through the list of remote media_files to try work out the date and
259
- # time so that we can copy it the correct local location
133
+ year = date_match.group(2)
134
+ month = date_match.group(3)
135
+ day = date_match.group(4)
260
136
 
261
- for media_file in media_files:
262
- year, month, day = get_media_date(media_file, media_files[media_file])
137
+ default_dest_dir = f'{dest_dir}/{year}/{year}-{month}-{day}'
138
+ dest_dir_pattern = f'{default_dest_dir}*'
263
139
 
264
- # If specified, skip files where the day of the month could not be determined
140
+ dest_dirs = [path for path in glob.glob(dest_dir_pattern) if os.path.isdir(path)]
265
141
 
266
- if skip_no_day and day == '00':
267
- day = None
142
+ sourcefile_name = os.path.basename(sourcefile)
268
143
 
269
- if year and month and day:
270
- destination_media_file_path = os.path.join(destination_dir, year, f'{year}-{month}-{day}', os.path.basename(media_file))
144
+ # Search any matching destination directories to see if the file exists
271
145
 
272
- if os.path.exists(destination_media_file_path):
273
- colour.write(f'[RED:WARNING]: Destination [BLUE:{destination_media_file_path}] already exists - file will not be overwritten!')
146
+ if dest_dirs:
147
+ for dest_dir in dest_dirs:
148
+ if os.path.isfile(os.path.join(dest_dir, sourcefile_name)):
149
+ break
274
150
  else:
275
- destination_dir_name = os.path.dirname(destination_media_file_path)
276
-
277
- colour.write(f'Copying [BLUE:{media_file}] to [BLUE:{destination_dir_name}]')
278
-
279
- if not dryrun:
280
- os.makedirs(destination_dir_name, exist_ok=True)
281
-
282
- shutil.copyfile(media_file, destination_media_file_path)
151
+ dest_dir = sorted(dest_dirs)[0]
283
152
  else:
284
- colour.write(f'[RED:ERROR]: Unable to determine where to copy [BLUE:{media_file}]')
285
-
286
- ################################################################################
287
-
288
- def local_directory(args, mediatype, year, month):
289
- """Return the location of the local picture directory for the specified year/month"""
290
-
291
- return os.path.join(args.local_dir[mediatype], str(year), f'{year}-{month:02}')
292
-
293
- ################################################################################
294
-
295
- def media_sync(dryrun, skip_no_day, media, media_files, local_dir):
296
- """Given a media type and list of local and remote files of the type, check
297
- for out-of-sync files and sync any missing remote files to local storage"""
298
-
299
- # Get the list of local and remote names of the specified media type
300
- # TODO: Could be a problem if we have multiple files with the same name (e.g. in different months)
301
-
302
- names = {'local': {}, 'remote': {}}
303
-
304
- for name in media_files['local']:
305
- names['local'][os.path.basename(name)] = name
306
-
307
- for name in media_files['remote']:
308
- names['remote'][os.path.basename(name)] = name
309
-
310
- # Find matches and remove them
311
-
312
- matching = 0
313
- for name in names['local']:
314
- if name in names['remote']:
315
- matching += 1
316
-
317
- del media_files['remote'][names['remote'][name]]
318
- del media_files['local'][names['local'][name]]
319
-
320
- if matching:
321
- colour.write(f' [BOLD:{matching} {media} files are in sync]')
322
- else:
323
- colour.write(f' [BOLD:No {media} files are in sync]')
324
-
325
- if media_files['local']:
326
- colour.write(f' [BOLD:{len(media_files["local"])} local {media} files are out of sync]')
327
- else:
328
- colour.write(f' [BOLD:No local {media} files are out of sync]')
329
-
330
- if media_files['remote']:
331
- colour.write(f' [BOLD:{len(media_files["remote"])} remote {media} files are out of sync]')
332
- sync_media_local(dryrun, skip_no_day, media_files['remote'], local_dir)
333
- else:
334
- colour.write(f' [BOLD:No remote {media} files are out of sync]')
335
-
336
- colour.write('')
337
-
338
- ################################################################################
339
-
340
- # TODO: Tidy this up!
341
- def remove_duplicates(media_files):
342
- """Look for remote files which have an original and multiple
343
- copies and remove the copies from the list of files to consider using the
344
- imagehash library to detect duplicate or near-duplicate files.
345
- """
346
-
347
- print('Checking for duplicate files')
348
-
349
- # Originals can have upper or lower case extensions, copies only tend to have lower
350
- # case, so build a lower case to original lookup table
351
-
352
- names = {name.lower():name for name in media_files}
353
-
354
- duplicates = defaultdict(list)
355
-
356
- # Build a list of duplicates for each filename in the list - i.e. files with the same
357
- # prefix and a suffix matching DUP_RE, indexed by the base filename (without the suffix)
358
-
359
- for entry in names:
360
- orig_match = DUP_RE.fullmatch(entry)
361
- if orig_match:
362
- original = orig_match.group(1) + orig_match.group(2)
363
-
364
- duplicates[original].append(entry)
153
+ if not args.dryrun:
154
+ os.makedirs(default_dest_dir)
365
155
 
366
- # Now use the imagehash library to check each list of maybe-duplicate files
367
- # to build a list of actual duplicates (or at least nearly-indistinguishable images)
368
- # TODO: Better to build list of all hashes, then find near-duplicates
156
+ dest_dir = default_dest_dir
369
157
 
370
- actual_duplicates = set()
371
- for entry, dupes in duplicates.items():
372
- # If the base file (no suffix) exists use that as the base, otherwise
373
- # use the first duplicate (we can have a situation where we have duplicates
374
- # and no original).
158
+ dest_file = os.path.join(dest_dir, sourcefile_name)
375
159
 
376
- hash_list = defaultdict(list)
377
-
378
- # Start with the base file, it it exists
379
-
380
- if entry in names:
381
- try:
382
- base_hash = str(imagehash.average_hash(Image.open(names[entry])))
383
-
384
- hash_list[base_hash].append(names[entry])
385
- except OSError:
386
- pass
387
-
388
- # Calculate the hash of each of the potential duplicates and if they
389
- # are close enough to the base hash, then add them to the real duplicate list
390
-
391
- for entry in dupes:
392
- filename = names[entry]
393
- try:
394
- dupe_hash = str(imagehash.average_hash(Image.open(filename)))
395
-
396
- hash_list[dupe_hash].append(filename)
397
- except OSError:
398
- colour.write(f'[BOLD:WARNING]: Unable to read {filename}')
399
-
400
- # Remove entries with identical hash values
401
-
402
- for dupes in hash_list:
403
- for dupe in hash_list[dupes][1:]:
404
- actual_duplicates.add(dupe)
405
- hash_list[dupes] = hash_list[dupes][0]
406
-
407
- # Look for adjaced entries in the sorted list of hash values that differ by less then the minimum
408
- # and remove the duplicates
409
-
410
- hash_values = sorted(hash_list.keys())
411
- logging.debug('Hash values for duplicates: %s', hash_values)
412
-
413
- for i in range(len(hash_values)-1):
414
- if int(hash_values[i+1], 16) - int(hash_values[i], 16) < MIN_HASH_DIFF:
415
- actual_duplicates.add(hash_list[hash_values[i+1]])
416
-
417
- # Remove all the entries in the real duplicates list
418
-
419
- for entry in actual_duplicates:
420
- logging.info('Removing %s as a (near-)duplicate', os.path.basename(entry))
421
- del media_files[entry]
422
-
423
- ################################################################################
424
-
425
- def photo_sync(args):
426
- """Synchronise the photos"""
427
-
428
- colour.write('[GREEN:%s]' % '-'*80)
429
-
430
- # Read the pictures and their EXIF data to get the dates
431
-
432
- media_files = {'photo': {}, 'video': {}}
433
- unknown_files = {}
434
-
435
- media_files['photo']['remote'], media_files['video']['remote'], unknown_files['remote'] = find_files([args.path])
436
- media_files['photo']['local'], media_files['video']['local'], unknown_files['local'] = find_files([args.picturedir, args.videodir])
437
-
438
- for media in ('photo', 'video'):
439
- remove_duplicates(media_files[media]['remote'])
440
-
441
- colour.write('[GREEN:%s]' % '-'*80)
442
-
443
- media_sync(args.dryrun, args.skip_no_day, media, media_files['photo'], args.picturedir)
444
- media_sync(args.dryrun, args.skip_no_day, media, media_files['video'], args.videodir)
445
-
446
- ################################################################################
447
-
448
- def main():
449
- """Entry point"""
160
+ if os.path.exists(dest_file):
161
+ logging.debug('Destination file %s already exists', dest_file)
162
+ else:
163
+ logging.info('Copying %s to %s', sourcefile, dest_file)
450
164
 
451
- # Handle the command line
165
+ if not args.dryrun:
166
+ shutil.copyfile(sourcefile, dest_file)
452
167
 
453
- args = parse_command_line()
168
+ files_copied += 1
454
169
 
455
- photo_sync(args)
170
+ print(f'{files_copied} files copied')
456
171
 
457
172
  ################################################################################
458
173
 
459
174
  def localphotosync():
460
175
  """Entry point"""
461
176
  try:
462
- main()
177
+ args = parse_command_line()
178
+
179
+ media_sync(args)
180
+
463
181
  except KeyboardInterrupt:
464
182
  sys.exit(1)
183
+
465
184
  except BrokenPipeError:
466
185
  sys.exit(2)
467
186