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.
- skilleter_thingy/__init__.py +0 -0
- skilleter_thingy/addpath.py +107 -0
- skilleter_thingy/aws.py +548 -0
- skilleter_thingy/borger.py +269 -0
- skilleter_thingy/colour.py +213 -0
- skilleter_thingy/console_colours.py +63 -0
- skilleter_thingy/dc_curses.py +278 -0
- skilleter_thingy/dc_defaults.py +221 -0
- skilleter_thingy/dc_util.py +50 -0
- skilleter_thingy/dircolors.py +308 -0
- skilleter_thingy/diskspacecheck.py +67 -0
- skilleter_thingy/docker.py +95 -0
- skilleter_thingy/docker_purge.py +113 -0
- skilleter_thingy/ffind.py +536 -0
- skilleter_thingy/files.py +142 -0
- skilleter_thingy/ggit.py +90 -0
- skilleter_thingy/ggrep.py +154 -0
- skilleter_thingy/git.py +1368 -0
- skilleter_thingy/git2.py +1307 -0
- skilleter_thingy/git_br.py +180 -0
- skilleter_thingy/git_ca.py +142 -0
- skilleter_thingy/git_cleanup.py +287 -0
- skilleter_thingy/git_co.py +220 -0
- skilleter_thingy/git_common.py +61 -0
- skilleter_thingy/git_hold.py +154 -0
- skilleter_thingy/git_mr.py +92 -0
- skilleter_thingy/git_parent.py +77 -0
- skilleter_thingy/git_review.py +1416 -0
- skilleter_thingy/git_update.py +385 -0
- skilleter_thingy/git_wt.py +96 -0
- skilleter_thingy/gitcmp_helper.py +322 -0
- skilleter_thingy/gitlab.py +193 -0
- skilleter_thingy/gitprompt.py +274 -0
- skilleter_thingy/gl.py +174 -0
- skilleter_thingy/gphotosync.py +610 -0
- skilleter_thingy/linecount.py +155 -0
- skilleter_thingy/logger.py +112 -0
- skilleter_thingy/moviemover.py +133 -0
- skilleter_thingy/path.py +156 -0
- skilleter_thingy/photodupe.py +110 -0
- skilleter_thingy/phototidier.py +248 -0
- skilleter_thingy/popup.py +87 -0
- skilleter_thingy/process.py +112 -0
- skilleter_thingy/py_audit.py +131 -0
- skilleter_thingy/readable.py +270 -0
- skilleter_thingy/remdir.py +126 -0
- skilleter_thingy/rmdupe.py +550 -0
- skilleter_thingy/rpylint.py +91 -0
- skilleter_thingy/run.py +334 -0
- skilleter_thingy/s3_sync.py +383 -0
- skilleter_thingy/splitpics.py +99 -0
- skilleter_thingy/strreplace.py +82 -0
- skilleter_thingy/sysmon.py +435 -0
- skilleter_thingy/tfm.py +920 -0
- skilleter_thingy/tfm_pane.py +595 -0
- skilleter_thingy/tfparse.py +101 -0
- skilleter_thingy/tidy.py +160 -0
- skilleter_thingy/trimpath.py +84 -0
- skilleter_thingy/window_rename.py +92 -0
- skilleter_thingy/xchmod.py +125 -0
- skilleter_thingy/yamlcheck.py +89 -0
- skilleter_thingy-0.0.22.dist-info/LICENSE +619 -0
- skilleter_thingy-0.0.22.dist-info/METADATA +22 -0
- skilleter_thingy-0.0.22.dist-info/RECORD +67 -0
- skilleter_thingy-0.0.22.dist-info/WHEEL +5 -0
- skilleter_thingy-0.0.22.dist-info/entry_points.txt +43 -0
- 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()
|
skilleter_thingy/path.py
ADDED
|
@@ -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()
|