skilleter-thingy 0.2.0__py3-none-any.whl → 0.2.1__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 (32) hide show
  1. skilleter_thingy/borger.py +273 -0
  2. skilleter_thingy/diskspacecheck.py +67 -0
  3. skilleter_thingy/ggit.py +1 -0
  4. skilleter_thingy/ggrep.py +1 -0
  5. skilleter_thingy/git_br.py +7 -0
  6. skilleter_thingy/git_ca.py +8 -0
  7. skilleter_thingy/git_cleanup.py +11 -0
  8. skilleter_thingy/git_co.py +8 -3
  9. skilleter_thingy/git_common.py +12 -4
  10. skilleter_thingy/git_hold.py +9 -0
  11. skilleter_thingy/git_mr.py +11 -0
  12. skilleter_thingy/git_parent.py +23 -18
  13. skilleter_thingy/git_retag.py +10 -0
  14. skilleter_thingy/git_review.py +1 -0
  15. skilleter_thingy/git_update.py +1 -0
  16. skilleter_thingy/git_wt.py +2 -0
  17. skilleter_thingy/gitprompt.py +1 -0
  18. skilleter_thingy/localphotosync.py +201 -0
  19. skilleter_thingy/moviemover.py +133 -0
  20. skilleter_thingy/photodupe.py +135 -0
  21. skilleter_thingy/phototidier.py +248 -0
  22. skilleter_thingy/splitpics.py +99 -0
  23. skilleter_thingy/sysmon.py +435 -0
  24. skilleter_thingy/thingy/git.py +18 -5
  25. skilleter_thingy/thingy/git2.py +20 -7
  26. skilleter_thingy/window_rename.py +92 -0
  27. {skilleter_thingy-0.2.0.dist-info → skilleter_thingy-0.2.1.dist-info}/METADATA +46 -1
  28. {skilleter_thingy-0.2.0.dist-info → skilleter_thingy-0.2.1.dist-info}/RECORD +32 -23
  29. {skilleter_thingy-0.2.0.dist-info → skilleter_thingy-0.2.1.dist-info}/entry_points.txt +9 -0
  30. {skilleter_thingy-0.2.0.dist-info → skilleter_thingy-0.2.1.dist-info}/WHEEL +0 -0
  31. {skilleter_thingy-0.2.0.dist-info → skilleter_thingy-0.2.1.dist-info}/licenses/LICENSE +0 -0
  32. {skilleter_thingy-0.2.0.dist-info → skilleter_thingy-0.2.1.dist-info}/top_level.txt +0 -0
@@ -7,9 +7,11 @@ the same commit. Can optionally ignore feature branches and/or report
7
7
  the distance to the potential parent.
8
8
  """
9
9
 
10
+ import os
10
11
  import argparse
11
12
  import sys
12
13
 
14
+ # TODO: Update to git2
13
15
  import thingy.git as git
14
16
  import thingy.colour as colour
15
17
 
@@ -23,33 +25,34 @@ def main():
23
25
  parser = argparse.ArgumentParser(description='Attempt to determine the parent branch for the specified branch (defaulting to the current one)')
24
26
  parser.add_argument('--all', '-a', action='store_true', help='Include feature branches as possible parents')
25
27
  parser.add_argument('--verbose', '-v', action='store_true', help='Report verbose results (includes number of commits between branch and parent)')
28
+ parser.add_argument('--path', '-C', nargs=1, type=str, default=None,
29
+ help='Run the command in the specified directory')
26
30
  parser.add_argument('branch', action='store', nargs='?', type=str, default=current_branch,
27
31
  help=f'Branch, commit or commit (defaults to current branch; {current_branch})')
28
32
 
29
33
  args = parser.parse_args()
30
34
 
31
- try:
32
- if args.all:
33
- any_parents, any_distance = git.parents(args.branch)
34
- else:
35
- any_parents = []
35
+ if args.path:
36
+ os.chdir(args.path[0])
36
37
 
37
- parents, distance = git.parents(args.branch, ignore='feature/*')
38
+ if args.all:
39
+ any_parents, any_distance = git.parents(args.branch)
40
+ else:
41
+ any_parents = []
38
42
 
39
- # If we have feature and non-feature branch candidates, decide which to report
40
- # (one or both) based on distance.
43
+ parents, distance = git.parents(args.branch, ignore='feature/*')
41
44
 
42
- if parents and any_parents:
43
- if any_distance < distance:
44
- parents = any_parents
45
- distance = any_distance
46
- elif any_distance == distance:
47
- for more in any_parents:
48
- if more not in parents:
49
- parents.append(more)
45
+ # If we have feature and non-feature branch candidates, decide which to report
46
+ # (one or both) based on distance.
50
47
 
51
- except git.GitError as exc:
52
- colour.error(exc.msg, status=exc.status, prefix=True)
48
+ if parents and any_parents:
49
+ if any_distance < distance:
50
+ parents = any_parents
51
+ distance = any_distance
52
+ elif any_distance == distance:
53
+ for more in any_parents:
54
+ if more not in parents:
55
+ parents.append(more)
53
56
 
54
57
  if parents:
55
58
  if args.verbose:
@@ -73,6 +76,8 @@ def git_parent():
73
76
  sys.exit(1)
74
77
  except BrokenPipeError:
75
78
  sys.exit(2)
79
+ except git.GitError as exc:
80
+ colour.error(exc.msg, status=exc.status, prefix=True)
76
81
 
77
82
  ################################################################################
78
83
 
@@ -9,6 +9,7 @@
9
9
  """
10
10
  ################################################################################
11
11
 
12
+ import os
12
13
  import sys
13
14
  import argparse
14
15
 
@@ -23,10 +24,17 @@ def main():
23
24
 
24
25
  parser = argparse.ArgumentParser(description='Apply or update a tag, optionally updating it on the remote as well.')
25
26
  parser.add_argument('--push', '-p', action='store_true', help='Push the tag to the remote')
27
+ parser.add_argument('--path', '-C', nargs=1, type=str, default=None,
28
+ help='Run the command in the specified directory')
26
29
  parser.add_argument('tag', nargs=1, help='The tag')
27
30
 
28
31
  args = parser.parse_args()
29
32
 
33
+ # Change directory, if specified
34
+
35
+ if args.path:
36
+ os.chdir(args.path[0])
37
+
30
38
  tag = args.tag[0]
31
39
 
32
40
  # Delete the tag if it currently exists, optionally pushing the deletion
@@ -49,6 +57,8 @@ def git_retag():
49
57
  sys.exit(1)
50
58
  except BrokenPipeError:
51
59
  sys.exit(2)
60
+ except git.GitError as exc:
61
+ colour.error(exc.msg, status=exc.status, prefix=True)
52
62
 
53
63
  ################################################################################
54
64
 
@@ -54,6 +54,7 @@ import subprocess
54
54
  import time
55
55
  from enum import IntEnum
56
56
 
57
+ # TODO: Update to git2
57
58
  import thingy.git as git
58
59
  import thingy.dc_curses as dc_curses
59
60
  import thingy.colour as colour
@@ -27,6 +27,7 @@ import argparse
27
27
  import fnmatch
28
28
  import logging
29
29
 
30
+ # TODO: Update to git2
30
31
  import thingy.git as git
31
32
  import thingy.colour as colour
32
33
 
@@ -62,6 +62,8 @@ def git_wt():
62
62
  sys.exit(1)
63
63
  except BrokenPipeError:
64
64
  sys.exit(2)
65
+ except git.GitError as exc:
66
+ colour.error(exc.msg, status=exc.status, prefix=True)
65
67
 
66
68
  ################################################################################
67
69
 
@@ -49,6 +49,7 @@ import os
49
49
  import sys
50
50
  import argparse
51
51
 
52
+ # TODO: Update to git2
52
53
  import thingy.git as git
53
54
  import thingy.colour as colour
54
55
 
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """
4
+ Sync a directory tree full of photos into a tree organised by year, month and date
5
+ """
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
+
10
+ import os
11
+ import glob
12
+ import shutil
13
+ import sys
14
+ import logging
15
+ import argparse
16
+ import re
17
+ import datetime
18
+
19
+ from enum import Enum
20
+
21
+ ################################################################################
22
+
23
+ # Default locations for local storage of photos and videos
24
+
25
+ DEFAULT_PHOTO_DIR = os.path.expanduser('~/Pictures')
26
+ DEFAULT_VIDEO_DIR = os.path.expanduser('~/Videos')
27
+
28
+ # File extensions (case-insensitive)
29
+
30
+ IMAGE_EXTENSIONS = ('.jpg', '.jpeg', '.png')
31
+ VIDEO_EXTENSIONS = ('.mp4', '.mov')
32
+
33
+ # Enum of filetypes
34
+
35
+ class FileType(Enum):
36
+ """File types"""
37
+ IMAGE = 0
38
+ VIDEO = 1
39
+ UNKNOWN = 2
40
+ IGNORE = 3
41
+
42
+ ################################################################################
43
+
44
+ def error(msg, status=1):
45
+ """Exit with an error message"""
46
+
47
+ print(msg)
48
+ sys.exit(status)
49
+
50
+ ################################################################################
51
+
52
+ def parse_command_line():
53
+ """Parse and validate the command line options"""
54
+
55
+ parser = argparse.ArgumentParser(description='Sync photos from Google Photos')
56
+
57
+ parser.add_argument('--verbose', '-v', action='store_true', help='Output verbose status information')
58
+ parser.add_argument('--dryrun', '--dry-run', '-D', action='store_true', help='Just list files to be copied, without actually copying them')
59
+ parser.add_argument('--picturedir', '-P', action='store', default=DEFAULT_PHOTO_DIR,
60
+ help=f'Location of local picture storage directory (defaults to {DEFAULT_PHOTO_DIR})')
61
+ parser.add_argument('--videodir', '-V', action='store', default=DEFAULT_VIDEO_DIR,
62
+ help=f'Location of local video storage directory (defaults to {DEFAULT_VIDEO_DIR})')
63
+ parser.add_argument('--path', '-p', action='store', default=None, help='Path to sync from')
64
+
65
+ args = parser.parse_args()
66
+
67
+ if not args.path:
68
+ error('You must specify a source directory')
69
+
70
+ # Configure debugging
71
+
72
+ logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO)
73
+
74
+ # Report parameters if verbose
75
+
76
+ logging.debug('Source: %s', args.path)
77
+ logging.debug('Pictures: %s', args.picturedir)
78
+ logging.debug('Videos: %s', args.videodir)
79
+ logging.debug('Dry run: %d', args.dryrun)
80
+
81
+ return args
82
+
83
+ ################################################################################
84
+
85
+ def get_filetype(filename):
86
+ """Return the type of a file"""
87
+
88
+ _, ext = os.path.splitext(filename)
89
+
90
+ ext = ext.lower()
91
+
92
+ if ext in IMAGE_EXTENSIONS:
93
+ return FileType.IMAGE
94
+
95
+ if ext in VIDEO_EXTENSIONS:
96
+ return FileType.VIDEO
97
+
98
+ return FileType.UNKNOWN
99
+
100
+ ################################################################################
101
+
102
+ def media_sync(args):
103
+ """Sync photos and videos from args.path to date-structured directory
104
+ trees in args.picturedir and args.videodir.
105
+ Assumes that the source files are in Android naming format:
106
+ (IMG|VID)_YYYYMMDD_*.(jpg|mp4)
107
+ Looks for a destination directory called:
108
+ YYYY/YYYY-MM-DD*/
109
+ If multiple destination directories exist, it uses the first one when the
110
+ names are sorted alphbetically
111
+ If a file with the same name exists in the destination directory it is
112
+ not overwritten"""
113
+
114
+ files_copied = 0
115
+
116
+ filetype_re = re.compile(r'(PANO|IMG|VID)[-_](\d{4})(\d{2})(\d{2})[-_.].*')
117
+
118
+ for sourcefile in [source for source in glob.glob(os.path.join(args.path, '*')) if os.path.isfile(source)]:
119
+ filetype = get_filetype(sourcefile)
120
+
121
+ if filetype == FileType.IMAGE:
122
+ dest_dir = args.picturedir
123
+ elif filetype == FileType.VIDEO:
124
+ dest_dir = args.videodir
125
+ else:
126
+ logging.info('Ignoring %s - unable to determine file type', sourcefile)
127
+ continue
128
+
129
+ date_match = filetype_re.fullmatch(os.path.basename(sourcefile))
130
+ if date_match:
131
+ year = date_match.group(2)
132
+ month = date_match.group(3)
133
+ day = date_match.group(4)
134
+
135
+ default_dest_dir = f'{dest_dir}/{year}/{year}-{month}-{day}'
136
+ else:
137
+ logging.debug('Ignoring %s - unable to extract date from filename - using last-modified date', sourcefile)
138
+
139
+ timestamp = os.path.getmtime(sourcefile)
140
+ last_modified_date = datetime.datetime.fromtimestamp(timestamp)
141
+
142
+ year = last_modified_date.year
143
+ month = last_modified_date.month
144
+ day = last_modified_date.day
145
+
146
+ default_dest_dir = f'{dest_dir}/{year:04}/{year:04}-{month:02}-{day:02}'
147
+
148
+ dest_dir_pattern = f'{default_dest_dir}*'
149
+
150
+ dest_dirs = [path for path in glob.glob(dest_dir_pattern) if os.path.isdir(path)]
151
+
152
+ sourcefile_name = os.path.basename(sourcefile)
153
+
154
+ # Search any matching destination directories to see if the file exists
155
+
156
+ if dest_dirs:
157
+ for dest_dir in dest_dirs:
158
+ if os.path.isfile(os.path.join(dest_dir, sourcefile_name)):
159
+ break
160
+ else:
161
+ dest_dir = sorted(dest_dirs)[0]
162
+ else:
163
+ if not args.dryrun:
164
+ os.makedirs(default_dest_dir)
165
+
166
+ dest_dir = default_dest_dir
167
+
168
+ dest_file = os.path.join(dest_dir, sourcefile_name)
169
+
170
+ if os.path.exists(dest_file):
171
+ logging.debug('Destination file %s already exists', dest_file)
172
+ else:
173
+ logging.info('Copying %s to %s', sourcefile, dest_file)
174
+
175
+ if not args.dryrun:
176
+ shutil.copyfile(sourcefile, dest_file)
177
+
178
+ files_copied += 1
179
+
180
+ if files_copied:
181
+ print(f'{files_copied} files copied')
182
+
183
+ ################################################################################
184
+
185
+ def localphotosync():
186
+ """Entry point"""
187
+ try:
188
+ args = parse_command_line()
189
+
190
+ media_sync(args)
191
+
192
+ except KeyboardInterrupt:
193
+ sys.exit(1)
194
+
195
+ except BrokenPipeError:
196
+ sys.exit(2)
197
+
198
+ ################################################################################
199
+
200
+ if __name__ == '__main__':
201
+ localphotosync()
@@ -0,0 +1,133 @@
1
+ #! /usr/bin/env python3
2
+
3
+ """ Search for files matching a wildcard in a directory tree and move them to an
4
+ equivalent location in a different tree """
5
+
6
+ import argparse
7
+ import os
8
+ import sys
9
+ import glob
10
+ import pathlib
11
+ import shutil
12
+ import filecmp
13
+
14
+ ################################################################################
15
+
16
+ def error(msg, status=1):
17
+ """ Exit with an error message """
18
+
19
+ print(msg)
20
+
21
+ sys.exit(status)
22
+
23
+ ################################################################################
24
+
25
+ def parse_command_line():
26
+ """ Handle command line arguments """
27
+
28
+ parser = argparse.ArgumentParser(description='File relocation - move files by wildcard from one directory tree to another')
29
+
30
+ parser.add_argument('--source', '-s', type=str, required=True, help='Source directory')
31
+ parser.add_argument('--destination', '-d', type=str, required=True, help='Destination directory')
32
+ parser.add_argument('--dry-run', '-D', action='store_true', help='Report what files would be moved, without actually moving them')
33
+ parser.add_argument('files', nargs='*', help='List of wildcard matches')
34
+
35
+ args = parser.parse_args()
36
+
37
+ if not args.files:
38
+ print('You must specify at least one wildcard/regex parameter')
39
+
40
+ if not os.path.isdir(args.source):
41
+ error(f'{args.source} is not a directory')
42
+
43
+ if not os.path.isdir(args.destination):
44
+ error(f'{args.destination} is not a directory')
45
+
46
+ args.source_path = pathlib.Path(os.path.realpath(args.source))
47
+ args.destination_path = pathlib.Path(os.path.realpath(args.destination))
48
+
49
+ if args.source_path == args.destination_path:
50
+ error('Source and destination paths cannot be the same')
51
+
52
+ if args.source_path in args.destination_path.parents:
53
+ error('The destination directory cannot be within the source path')
54
+
55
+ if args.destination_path in args.source_path.parents:
56
+ error('The source directory cannot be within the destination path')
57
+
58
+ return args
59
+
60
+ ################################################################################
61
+
62
+ def main():
63
+ """ Entry point """
64
+
65
+ args = parse_command_line()
66
+
67
+ # Process each wildcard
68
+
69
+ for wild in args.files:
70
+ # Find matching files in the source tree
71
+
72
+ for source_file in args.source_path.glob(f'**/{wild}'):
73
+ # Ignore anything that isn't a file
74
+
75
+ if source_file.is_file():
76
+ # Determine where to put it
77
+
78
+ dest_file = args.destination_path / source_file.relative_to(args.source_path)
79
+
80
+ if dest_file.exists():
81
+
82
+ if filecmp.cmp(source_file, dest_file, shallow=False):
83
+ print(f'Destination file {dest_file} already exists and is identical, so deleting source')
84
+ if not args.dry_run:
85
+ os.unlink(source_file)
86
+ else:
87
+ print(f'Destination file {dest_file} already exists and is DIFFERENT')
88
+ else:
89
+ # If the destination directory doesn't exist, then create it
90
+
91
+ if not dest_file.parent.is_dir():
92
+ print(f'Creating directory {dest_file.parent}')
93
+
94
+ if not args.dry_run:
95
+ dest_file.parent.mkdir(parents=True, exist_ok=True)
96
+
97
+ # Move the file
98
+
99
+ print(f'Moving {source_file.name} to {dest_file.parent}')
100
+
101
+ if not args.dry_run:
102
+ try:
103
+ shutil.move(source_file, dest_file)
104
+ except PermissionError:
105
+ print(f'WARNING: Permissions error moving {source_file}')
106
+
107
+ # Delete the source directory if it is not empty
108
+
109
+ source_dir = os.path.dirname(source_file)
110
+
111
+ if not glob.glob(source_dir, recursive=True):
112
+ print('Deleting directory "{source_dir}"')
113
+ if not args.dry_run:
114
+ os.path.unlink(source_dir)
115
+
116
+ ################################################################################
117
+
118
+ def moviemover():
119
+ """Entry point"""
120
+
121
+ try:
122
+ main()
123
+
124
+ except KeyboardInterrupt:
125
+ sys.exit(1)
126
+
127
+ except BrokenPipeError:
128
+ sys.exit(2)
129
+
130
+ ################################################################################
131
+
132
+ if __name__ == '__main__':
133
+ moviemover()
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env python3
2
+ """Hash photos to find closely-similar images and report them"""
3
+
4
+ import sys
5
+ import os
6
+ import pickle
7
+ import argparse
8
+
9
+ import PIL
10
+
11
+ from collections import defaultdict
12
+
13
+ from PIL import Image
14
+ import imagehash
15
+
16
+ ################################################################################
17
+
18
+ def read_image_hashes(directories):
19
+ """Read all the specfied directories and hash every picture therein"""
20
+
21
+ hashes = defaultdict(list)
22
+
23
+ # Walk each directory tree
24
+
25
+ for directory in directories:
26
+ print(f'Scanning directory tree {directory}')
27
+
28
+ for root, _, files in os.walk(directory):
29
+ print(f'Scanning directory {root}')
30
+
31
+ for file in files:
32
+ filepath = os.path.join(root, file)
33
+
34
+ fileext = os.path.splitext(file)[1]
35
+
36
+ if fileext.lower() not in ('.jbf', '.ini', '.xml', '.ffs_db'):
37
+ # Calculate the hash and store path, dimensions and file size under the hash entry in the hashes table
38
+
39
+ try:
40
+ with Image.open(filepath) as image:
41
+ hash_value = imagehash.average_hash(image, hash_size=12)
42
+
43
+ size = os.stat(filepath).st_size
44
+ hashes[hash_value].append({'path': filepath, 'width': image.width, 'height': image.height, 'size': size})
45
+
46
+ except PIL.UnidentifiedImageError:
47
+ sys.stderr.write(f'ERROR: Unrecognized format {filepath} (size={size})\n')
48
+
49
+ except OSError:
50
+ sys.stderr.write(f'ERROR: Unable to read {filepath} (size={size})\n')
51
+
52
+ # Return the hash table
53
+
54
+ return hashes
55
+
56
+ ################################################################################
57
+
58
+ def main():
59
+ """Read the hashes and report duplicates in a vaguely civilised way"""
60
+
61
+ parser = argparse.ArgumentParser(description='Search for similar images')
62
+ parser.add_argument('directories', nargs='*', action='store', help='Directories to search')
63
+
64
+ args = parser.parse_args()
65
+
66
+ if not args.directories:
67
+ print('You must be specify at least one directory')
68
+ sys.exit(1)
69
+
70
+ try:
71
+ print('Loading cached data')
72
+
73
+ with open('photodupe.pickle', 'rb') as pickles:
74
+ hashes = pickle.load(pickles)
75
+ except (FileNotFoundError, EOFError):
76
+ print('Scanning directories')
77
+
78
+ hashes = read_image_hashes(args.directories)
79
+
80
+ # Sort the list of hashes so that we can easily find close matches
81
+
82
+ print('Sorting hashes')
83
+
84
+ hash_values = sorted([str(hashval) for hashval in hashes])
85
+
86
+ for hash_value in hash_values:
87
+ if len(hashes[hash_value]) > 1:
88
+ print(hash_value)
89
+ max_len = 0
90
+ min_size = None
91
+
92
+ for entry in hashes[hash_value]:
93
+ max_len = max(max_len, len(entry['path']))
94
+
95
+ if min_size is None:
96
+ min_size = entry['size']
97
+ else:
98
+ min_size = min(min_size, entry['size'])
99
+
100
+ if min_size >= 1024 * 1024:
101
+ size_suffix = 'MiB'
102
+ size_div = 1024*1024
103
+
104
+ elif min_size > 1024:
105
+ size_suffix = 'KiB'
106
+ size_div = 1024
107
+ else:
108
+ size_div = 1
109
+ size_suffix = ''
110
+
111
+ for entry in hashes[hash_value]:
112
+ size = entry['size'] // size_div
113
+ print(f' {entry["path"]:{max_len}} {size:>4} {size_suffix} ({entry["width"]}x{entry["height"]})')
114
+
115
+ with open('photodupe.pickle', 'wb') as pickles:
116
+ pickle.dump(hashes, pickles)
117
+
118
+ ################################################################################
119
+
120
+ def photodupe():
121
+ """Entry point"""
122
+
123
+ try:
124
+ main()
125
+
126
+ except KeyboardInterrupt:
127
+ sys.exit(1)
128
+
129
+ except BrokenPipeError:
130
+ sys.exit(2)
131
+
132
+ ################################################################################
133
+
134
+ if __name__ == '__main__':
135
+ photodupe()