skilleter-thingy 0.0.22__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 (67) hide show
  1. skilleter_thingy/__init__.py +0 -0
  2. skilleter_thingy/addpath.py +107 -0
  3. skilleter_thingy/aws.py +548 -0
  4. skilleter_thingy/borger.py +269 -0
  5. skilleter_thingy/colour.py +213 -0
  6. skilleter_thingy/console_colours.py +63 -0
  7. skilleter_thingy/dc_curses.py +278 -0
  8. skilleter_thingy/dc_defaults.py +221 -0
  9. skilleter_thingy/dc_util.py +50 -0
  10. skilleter_thingy/dircolors.py +308 -0
  11. skilleter_thingy/diskspacecheck.py +67 -0
  12. skilleter_thingy/docker.py +95 -0
  13. skilleter_thingy/docker_purge.py +113 -0
  14. skilleter_thingy/ffind.py +536 -0
  15. skilleter_thingy/files.py +142 -0
  16. skilleter_thingy/ggit.py +90 -0
  17. skilleter_thingy/ggrep.py +154 -0
  18. skilleter_thingy/git.py +1368 -0
  19. skilleter_thingy/git2.py +1307 -0
  20. skilleter_thingy/git_br.py +180 -0
  21. skilleter_thingy/git_ca.py +142 -0
  22. skilleter_thingy/git_cleanup.py +287 -0
  23. skilleter_thingy/git_co.py +220 -0
  24. skilleter_thingy/git_common.py +61 -0
  25. skilleter_thingy/git_hold.py +154 -0
  26. skilleter_thingy/git_mr.py +92 -0
  27. skilleter_thingy/git_parent.py +77 -0
  28. skilleter_thingy/git_review.py +1416 -0
  29. skilleter_thingy/git_update.py +385 -0
  30. skilleter_thingy/git_wt.py +96 -0
  31. skilleter_thingy/gitcmp_helper.py +322 -0
  32. skilleter_thingy/gitlab.py +193 -0
  33. skilleter_thingy/gitprompt.py +274 -0
  34. skilleter_thingy/gl.py +174 -0
  35. skilleter_thingy/gphotosync.py +610 -0
  36. skilleter_thingy/linecount.py +155 -0
  37. skilleter_thingy/logger.py +112 -0
  38. skilleter_thingy/moviemover.py +133 -0
  39. skilleter_thingy/path.py +156 -0
  40. skilleter_thingy/photodupe.py +110 -0
  41. skilleter_thingy/phototidier.py +248 -0
  42. skilleter_thingy/popup.py +87 -0
  43. skilleter_thingy/process.py +112 -0
  44. skilleter_thingy/py_audit.py +131 -0
  45. skilleter_thingy/readable.py +270 -0
  46. skilleter_thingy/remdir.py +126 -0
  47. skilleter_thingy/rmdupe.py +550 -0
  48. skilleter_thingy/rpylint.py +91 -0
  49. skilleter_thingy/run.py +334 -0
  50. skilleter_thingy/s3_sync.py +383 -0
  51. skilleter_thingy/splitpics.py +99 -0
  52. skilleter_thingy/strreplace.py +82 -0
  53. skilleter_thingy/sysmon.py +435 -0
  54. skilleter_thingy/tfm.py +920 -0
  55. skilleter_thingy/tfm_pane.py +595 -0
  56. skilleter_thingy/tfparse.py +101 -0
  57. skilleter_thingy/tidy.py +160 -0
  58. skilleter_thingy/trimpath.py +84 -0
  59. skilleter_thingy/window_rename.py +92 -0
  60. skilleter_thingy/xchmod.py +125 -0
  61. skilleter_thingy/yamlcheck.py +89 -0
  62. skilleter_thingy-0.0.22.dist-info/LICENSE +619 -0
  63. skilleter_thingy-0.0.22.dist-info/METADATA +22 -0
  64. skilleter_thingy-0.0.22.dist-info/RECORD +67 -0
  65. skilleter_thingy-0.0.22.dist-info/WHEEL +5 -0
  66. skilleter_thingy-0.0.22.dist-info/entry_points.txt +43 -0
  67. skilleter_thingy-0.0.22.dist-info/top_level.txt +1 -0
@@ -0,0 +1,155 @@
1
+ #! /usr/bin/env python3
2
+
3
+ ################################################################################
4
+ """ Count lines of code by filetype
5
+ """
6
+ ################################################################################
7
+
8
+ import os
9
+ import sys
10
+ import argparse
11
+
12
+ import filetype
13
+
14
+ from skilleter_thingy import files
15
+
16
+ ################################################################################
17
+
18
+ def guess_filetype(filepath):
19
+ """ Guess the type of a file """
20
+
21
+ binary = False
22
+
23
+ # Check for the filetype (usually detects binary files)
24
+
25
+ ftype = filetype.guess(filepath)
26
+
27
+ # If we have a type, store the extension and, as it is binary
28
+ # set the number of lines to 0
29
+ # Otherwise, work out the filetype and count the lines
30
+
31
+ if ftype:
32
+ ext = ftype.extension
33
+ binary = True
34
+ else:
35
+ filename = os.path.split(filepath)[1]
36
+
37
+ if '.' in filename:
38
+ ext = filename.split('.')[-1]
39
+ else:
40
+ if filename.startswith('Jenkins'):
41
+ ext = 'Jenkins'
42
+ elif filename.startswith('Docker'):
43
+ ext = 'Docker'
44
+ else:
45
+ ext = filename
46
+
47
+ return ext, binary
48
+
49
+ ################################################################################
50
+
51
+ def determine_filetype(filepath):
52
+ """ Determine the type of a file """
53
+
54
+ file_type = files.file_type(filepath)
55
+
56
+ if file_type.startswith('a /usr/bin/env '):
57
+ file_type = file_type[len('a /usr/bin/env '):]
58
+ elif file_type.startswith('symbolic link to '):
59
+ file_type = 'Symbolic link'
60
+
61
+ if file_type[0].islower():
62
+ file_type = file_type.capitalize()
63
+
64
+ ext = file_type.split(',')[0]
65
+ binary = 'text' not in file_type
66
+
67
+ if file_type.startswith('ASCII text'):
68
+ return guess_filetype(filepath)
69
+
70
+ return ext, binary
71
+
72
+ ################################################################################
73
+
74
+ def main():
75
+ """ Report Summary of files by name or extension """
76
+
77
+ parser = argparse.ArgumentParser(description='Summarise number of files, lines of text and total size of files in a directory tree')
78
+ parser.add_argument('-e-', '--ext', action='store_true', help='Identify file type using the file extension (faster but less accurrate)')
79
+
80
+ args = parser.parse_args()
81
+
82
+ filetypes = {}
83
+
84
+ # Wander down the tree
85
+
86
+ for dirpath, dirnames, filenames in os.walk('.'):
87
+ # Skip .git directories
88
+
89
+ if '.git' in dirnames:
90
+ dirnames.remove('.git')
91
+
92
+ for filename in filenames:
93
+ # Get the file path and size
94
+
95
+ filepath = os.path.join(dirpath, filename)
96
+ size = os.stat(filepath).st_size
97
+
98
+ if args.ext:
99
+ ext, binary = guess_filetype(filepath)
100
+ else:
101
+ ext, binary = determine_filetype(filepath)
102
+
103
+ if binary:
104
+ lines = 0
105
+ else:
106
+ with open(filepath, 'rb') as infile:
107
+ lines = len(infile.readlines())
108
+
109
+ # Update the summary
110
+
111
+ if ext in filetypes:
112
+ filetypes[ext]['files'] += 1
113
+ filetypes[ext]['size'] += size
114
+ filetypes[ext]['lines'] += lines
115
+ else:
116
+ filetypes[ext] = {'files': 1, 'size': size, 'lines': lines}
117
+
118
+ # Work out the maximum size of each field of data
119
+
120
+ total_files = 0
121
+ total_lines = 0
122
+ total_size = 0
123
+
124
+ for ext in sorted(filetypes.keys()):
125
+ total_files += filetypes[ext]['files']
126
+ total_lines += filetypes[ext]['lines']
127
+ total_size += filetypes[ext]['size']
128
+
129
+ size = files.format_size(filetypes[ext]['size'])
130
+ print(f"{ext}: {filetypes[ext]['files']:,} files, {filetypes[ext]['lines']:,} lines, {size}")
131
+
132
+ size = files.format_size(total_size)
133
+
134
+ print()
135
+ print(f'Total files: {total_files:,}')
136
+ print(f'Total lines: {total_lines:,}')
137
+ print(f'Total size: {size}')
138
+
139
+ ################################################################################
140
+
141
+ def linecount():
142
+ """Entry point"""
143
+
144
+ try:
145
+ main()
146
+
147
+ except KeyboardInterrupt:
148
+ sys.exit(1)
149
+ except BrokenPipeError:
150
+ sys.exit(2)
151
+
152
+ ################################################################################
153
+
154
+ if __name__ == '__main__':
155
+ linecount()
@@ -0,0 +1,112 @@
1
+ #! /usr/bin/env python3
2
+
3
+ ################################################################################
4
+ """ Thingy logging functionality - wraps the Pythong logging module
5
+
6
+ Copyright (c) 2017 John Skilleter
7
+
8
+ Licence: GPL v3 or later
9
+ """
10
+ ################################################################################
11
+
12
+ import os
13
+
14
+ import logging
15
+
16
+ ################################################################################
17
+
18
+ CRITICAL = logging.CRITICAL
19
+ ERROR = logging.ERROR
20
+ WARNING = logging.WARNING
21
+ INFO = logging.INFO
22
+ DEBUG = logging.DEBUG
23
+ NOTSET = logging.NOTSET
24
+
25
+ LOG_LEVELS = {
26
+ 'CRITICAL': CRITICAL, 'ERROR': ERROR, 'WARNING': WARNING, 'INFO': INFO,
27
+ 'DEBUG': DEBUG, 'NOTSET': NOTSET
28
+ }
29
+
30
+ __config_done__ = False
31
+
32
+ ################################################################################
33
+
34
+ def set_logging(log, name):
35
+ """ If an environment variable called NAME_DEBUG is set and defines a
36
+ log level that is more verbose than the current level then set
37
+ that level (you can only increase verbosity via the variable, not
38
+ decrease it). """
39
+
40
+ # Check whether there is an environment variable setting the debug level
41
+
42
+ env_name = '%s_DEBUG' % name.upper()
43
+
44
+ value = os.getenv(env_name, None)
45
+
46
+ if value is not None:
47
+ value = value.upper()
48
+
49
+ current = log.getEffectiveLevel()
50
+
51
+ # Check for a textual level in the value and if no match, try
52
+ # for an integer level ignoring invalid values.
53
+
54
+ if value in LOG_LEVELS:
55
+ if current > LOG_LEVELS[value]:
56
+ log.setLevel(LOG_LEVELS[value])
57
+ else:
58
+ try:
59
+ intlevel = int(value)
60
+
61
+ if current > intlevel:
62
+ log.setLevel(intlevel)
63
+
64
+ except ValueError:
65
+ pass
66
+
67
+ return log
68
+
69
+ ################################################################################
70
+
71
+ def init(name):
72
+ """ Initilise logging and create a logger.
73
+ If the environment variable NAME_DEBUG is set to a value in LOG_LEVELS
74
+ then the log level is set to that level. If NAME_DEBUG is an integer
75
+ then the same applies, otherwise, by default, the log level is CRITICAL """
76
+
77
+ # Create the new logger
78
+
79
+ log = logging.getLogger(name)
80
+
81
+ # Default log level is CRITICAL
82
+
83
+ log.setLevel(CRITICAL)
84
+
85
+ # Set logging according to the value of THINGY_DEBUG (if set) then
86
+ # override with the logger-specific variable (again, if set)
87
+
88
+ set_logging(log, 'THINGY')
89
+ set_logging(log, name)
90
+
91
+ return log
92
+
93
+ ################################################################################
94
+ # Entry point
95
+
96
+ # Ensure that the logging module is initialise
97
+
98
+ if not __config_done__:
99
+ logging.basicConfig()
100
+ __config_done__ = True
101
+
102
+ if __name__ == '__main__':
103
+ demo = init('wombat')
104
+
105
+ demo.critical('Critical error')
106
+
107
+ # These messages should only appear if the WOMBAT_DEBUG environment variable
108
+ # is set to an appropriate value (ERROR, WARNING or INFO)
109
+
110
+ demo.error('Error message')
111
+ demo.warning('Warning message')
112
+ demo.info('Info message')
@@ -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('-s', '--source', type=str, required=True, help='Source directory')
31
+ parser.add_argument('-d', '--destination', type=str, required=True, help='Destination directory')
32
+ parser.add_argument('-D', '--dry-run', 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,156 @@
1
+ #! /usr/bin/env python3
2
+
3
+ ################################################################################
4
+ """ Thingy file and directory functionality
5
+
6
+ Copyright (C) 2017-18 John Skilleter
7
+
8
+ Licence: GPL v3 or later
9
+ """
10
+ ################################################################################
11
+
12
+ import os
13
+
14
+ from skilleter_thingy import logger
15
+
16
+ ################################################################################
17
+
18
+ class PathError(Exception):
19
+ """ Exception raised by the module """
20
+
21
+ def __init__(self, msg):
22
+ super(PathError, self).__init__(msg)
23
+ self.msg = msg
24
+
25
+ ################################################################################
26
+
27
+ def is_subdirectory(root_path, sub_path):
28
+ """ Return True if sub_path is a sub-directory of root_path """
29
+
30
+ abs_sub_path = os.path.abspath(sub_path)
31
+ abs_root_path = os.path.abspath(root_path)
32
+
33
+ log.debug('root path: %s', abs_root_path)
34
+ log.debug('sub path : %s', abs_sub_path)
35
+
36
+ return abs_sub_path.startswith('%s/' % abs_root_path)
37
+
38
+ ################################################################################
39
+
40
+ def trimpath(full_path, trim_width):
41
+ """ Trim a path to a specified maximum width, but always leaving the
42
+ lowest-level directory (even if it exceeds the trim width). """
43
+
44
+ log.debug('Path: "%s"', full_path)
45
+ log.debug('Required width: %d', trim_width)
46
+
47
+ full_path = os.path.abspath(full_path)
48
+
49
+ # Remove any trailing '/' from the path
50
+
51
+ if full_path != '/' and full_path[-1] == '/':
52
+ full_path = full_path[:-1]
53
+
54
+ # If the path starts with the user's home directory then convert the prefix
55
+ # into a '~'
56
+
57
+ home_dir = os.path.expanduser('~')
58
+
59
+ if full_path == home_dir:
60
+ full_path = '~'
61
+ log.debug('Converted path to "~"')
62
+
63
+ elif is_subdirectory(home_dir, full_path):
64
+ full_path = "~/%s" % full_path[len(home_dir) + 1:]
65
+
66
+ log.debug('Converted path to "%s"', full_path)
67
+
68
+ # If the path is too long then slice it into directories and cut sub-directories
69
+ # out of the middle until it is short enough. Always leave the last element
70
+ # in place, even if this means total length exceeds the requirement.
71
+
72
+ path_len = len(full_path)
73
+
74
+ log.debug('Path length: %d', path_len)
75
+
76
+ # Already within maximum width, so just return it
77
+
78
+ if path_len <= trim_width:
79
+ return full_path
80
+
81
+ # Split into an array of directories and trim out middle ones
82
+
83
+ directories = full_path.split('/')
84
+
85
+ log.debug('Path has %d elements: "%s"', len(directories), directories)
86
+
87
+ if len(directories) == 1:
88
+ # If there's only one element in the path, just give up
89
+
90
+ log.debug('Only 1 directory in the path, leaving it as-is')
91
+
92
+ elif len(directories) == 2:
93
+ # If there's only two elements in the path then replace the first
94
+ # element with '...' and give up
95
+
96
+ log.debug('Only 2 directories in the path, so setting the first to "..."')
97
+
98
+ directories[0] = '...'
99
+
100
+ else:
101
+ # Start in the middle and remove entries to the left and right until the total
102
+ # path length is shortened to a sufficient extent
103
+
104
+ right = len(directories) // 2
105
+ left = right - 1
106
+ first = True
107
+
108
+ while path_len > trim_width:
109
+
110
+ path_len -= len(directories[right]) + 1
111
+
112
+ if first:
113
+ path_len += 4
114
+ first = False
115
+
116
+ if right == len(directories) - 1:
117
+ break
118
+
119
+ right += 1
120
+
121
+ if path_len > trim_width:
122
+ path_len -= len(directories[left]) + 1
123
+
124
+ if left == 0:
125
+ break
126
+
127
+ left -= 1
128
+
129
+ log.debug('Removing entries %d..%d from the path', left, right)
130
+
131
+ directories = directories[0:left + 1] + ['...'] + directories[right:]
132
+
133
+ full_path = '/'.join(directories)
134
+
135
+ log.debug('Calculated width is %d and actual width is %d', path_len, len(full_path))
136
+
137
+ return full_path
138
+
139
+ ################################################################################
140
+
141
+ log = logger.init('tgy_path')
142
+
143
+ if __name__ == '__main__':
144
+ PARENT = '/1/2/3/5'
145
+ CHILD = '/1/2/3/5/6'
146
+
147
+ print('Is %s a subdirectory of %s: %s (expecting True)' % (CHILD, PARENT, is_subdirectory(PARENT, CHILD)))
148
+ print('Is %s a subdirectory of %s: %s (expecting False)' % (PARENT, CHILD, is_subdirectory(CHILD, PARENT)))
149
+
150
+ LONG_PATH = '/home/jms/source/womble-biscuit-token-generation-service/subdirectory'
151
+
152
+ for pathname in (LONG_PATH, os.path.realpath('.')):
153
+ print('Full path: %s' % pathname)
154
+
155
+ for length in (80, 60, 40, 20, 16, 10):
156
+ print('Trimmed to %d characters: %s' % (length, trimpath(pathname, length)))
@@ -0,0 +1,110 @@
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 sys
7
+ import pickle
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():
19
+ """Read all the specfied directories and hash every picture therein"""
20
+
21
+ hashes = defaultdict(list)
22
+
23
+ for directory in sys.argv[1:]:
24
+ for root, _, files in os.walk(directory):
25
+ for file in files:
26
+ filepath = os.path.join(root, file)
27
+
28
+ try:
29
+ with Image.open(filepath) as image:
30
+ hash_value = imagehash.average_hash(image, hash_size=12)
31
+
32
+ size = os.stat(filepath).st_size
33
+ hashes[hash_value].append({'path': filepath, 'width': image.width, 'height': image.height, 'size': size})
34
+
35
+ except PIL.UnidentifiedImageError:
36
+ sys.stderr.write(f'ERROR: Unrecognized format {filepath}\n')
37
+
38
+ except OSError:
39
+ sys.stderr.write(f'ERROR: Unable to read {filepath}\n')
40
+ return hashes
41
+
42
+ ################################################################################
43
+
44
+ def main():
45
+ """Read the hashes and report duplicates in a vaguely civilised way"""
46
+
47
+ try:
48
+ print('Loading cached data')
49
+
50
+ with open('photodupe.pickle', 'rb') as pickles:
51
+ hashes = pickle.load(pickles)
52
+ except (FileNotFoundError, EOFError):
53
+ print('Scanning directories')
54
+
55
+ hashes = read_image_hashes()
56
+
57
+ print('Sorting hashes')
58
+
59
+ hash_values = sorted([str(hashval) for hashval in hashes])
60
+
61
+ for hash_value in hash_values:
62
+ if len(hashes[hash_value]) > 1:
63
+ print(hash_value)
64
+ max_len = 0
65
+ min_size = None
66
+
67
+ for entry in hashes[hash_value]:
68
+ max_len = max(max_len, len(entry['path']))
69
+
70
+ if min_size is None:
71
+ min_size = entry['size']
72
+ else:
73
+ min_size = min(min_size, entry['size'])
74
+
75
+ if min_size >= 1024 * 1024:
76
+ size_suffix = 'MiB'
77
+ size_div = 1024*1024
78
+
79
+ elif min_size > 1024:
80
+ size_suffix = 'KiB'
81
+ size_div = 1024
82
+ else:
83
+ size_div = 1
84
+ size_suffix = ''
85
+
86
+ for entry in hashes[hash_value]:
87
+ size = entry['size'] // size_div
88
+ print(f' {entry["path"]:{max_len}} {size:>4} {size_suffix} ({entry["width"]}x{entry["height"]})')
89
+
90
+ with open('photodupe.pickle', 'wb') as pickles:
91
+ pickle.dump(hashes, pickles)
92
+
93
+ ################################################################################
94
+
95
+ def photodupe():
96
+ """Entry point"""
97
+
98
+ try:
99
+ main()
100
+
101
+ except KeyboardInterrupt:
102
+ sys.exit(1)
103
+
104
+ except BrokenPipeError:
105
+ sys.exit(2)
106
+
107
+ ################################################################################
108
+
109
+ if __name__ == '__main__':
110
+ photodupe()