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,248 @@
|
|
|
1
|
+
#! /usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
""" Perform various tidying operations on a directory full of photos:
|
|
4
|
+
1. Remove leading '$' and '_' from filenames
|
|
5
|
+
2. Move files in hidden directories up 1 level
|
|
6
|
+
3. If the EXIF data in a photo indicates that it was taken on date that
|
|
7
|
+
doesn't match the name of the directory it is stored in (in YYYY-MM-DD format)
|
|
8
|
+
then it is moved to the correct directory, creating it if necessary.
|
|
9
|
+
|
|
10
|
+
All move/rename operations are carried out safely with the file being moved having
|
|
11
|
+
a numeric suffix added to the name if it conflicts with an existing file.
|
|
12
|
+
|
|
13
|
+
TODO: Ignore .stversions files
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
################################################################################
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
import pathlib
|
|
23
|
+
import re
|
|
24
|
+
|
|
25
|
+
from PIL import UnidentifiedImageError
|
|
26
|
+
from PIL import Image
|
|
27
|
+
from PIL.ExifTags import TAGS
|
|
28
|
+
|
|
29
|
+
from skilleter_thingy import colour
|
|
30
|
+
|
|
31
|
+
################################################################################
|
|
32
|
+
|
|
33
|
+
FILE_TYPES = ('.jpg', '.jpeg')
|
|
34
|
+
|
|
35
|
+
DATE_RE = re.compile(r'[0-9]{4}-[0-9]{2}-[0-9]{2}')
|
|
36
|
+
|
|
37
|
+
NUMBER_RE = re.compile(r'(.*) +\([0-9]+\).*')
|
|
38
|
+
|
|
39
|
+
################################################################################
|
|
40
|
+
|
|
41
|
+
def error(msg, status=1):
|
|
42
|
+
""" Exit with an error message """
|
|
43
|
+
|
|
44
|
+
print(msg)
|
|
45
|
+
|
|
46
|
+
sys.exit(status)
|
|
47
|
+
|
|
48
|
+
################################################################################
|
|
49
|
+
|
|
50
|
+
def parse_command_line():
|
|
51
|
+
""" Handle command line arguments """
|
|
52
|
+
|
|
53
|
+
parser = argparse.ArgumentParser(description='Re-organise photos into (hopefully) the correct folders.')
|
|
54
|
+
|
|
55
|
+
parser.add_argument('-D', '--dry-run', action='store_true', help='Report what files would be moved, without actually moving them')
|
|
56
|
+
parser.add_argument('path', nargs=1, help='Path to the picture storage directory')
|
|
57
|
+
|
|
58
|
+
args = parser.parse_args()
|
|
59
|
+
|
|
60
|
+
if not os.path.isdir(args.path[0]):
|
|
61
|
+
error(f'{args.path} is not a directory')
|
|
62
|
+
|
|
63
|
+
args.path = pathlib.Path(os.path.realpath(args.path[0]))
|
|
64
|
+
|
|
65
|
+
return args
|
|
66
|
+
|
|
67
|
+
################################################################################
|
|
68
|
+
|
|
69
|
+
def safe_rename(args, source_file, new_name):
|
|
70
|
+
""" Rename a file, adding a numeric suffix to avoid overwriting anything """
|
|
71
|
+
|
|
72
|
+
# If the destination file exists, add a numeric suffix to the new name
|
|
73
|
+
# until we find one that doesn't
|
|
74
|
+
|
|
75
|
+
index = 1
|
|
76
|
+
new_name_stem = new_name.stem
|
|
77
|
+
|
|
78
|
+
while new_name.exists():
|
|
79
|
+
new_name = new_name.with_name(f'{new_name_stem}-{index}{new_name.suffix}')
|
|
80
|
+
index += 1
|
|
81
|
+
|
|
82
|
+
colour.write(f'Rename [BLUE:{source_file}] to [BLUE:{new_name}]')
|
|
83
|
+
|
|
84
|
+
# Panic if the destination parent directory exists, but isn't actually a directory
|
|
85
|
+
|
|
86
|
+
if new_name.parent.exists and not new_name.parent.is_dir:
|
|
87
|
+
colour.write('[RED:WARNING]: Destination [BLUE:{new_name.parent}] exists, but is not a directory - [BLUE:{source_file}] will not be renamed')
|
|
88
|
+
return source_file
|
|
89
|
+
|
|
90
|
+
# Rename and return the new namem, creating the directory for it to go in, if necessary
|
|
91
|
+
|
|
92
|
+
if not args.dry_run:
|
|
93
|
+
new_name.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
|
|
95
|
+
source_file.rename(new_name)
|
|
96
|
+
|
|
97
|
+
return new_name
|
|
98
|
+
|
|
99
|
+
################################################################################
|
|
100
|
+
|
|
101
|
+
def get_exif_date(source_file):
|
|
102
|
+
""" Try an extract the daste when the photo was taken from the EXIF data
|
|
103
|
+
and return it in YYYY/YYYY-MM-DD format as the subdirectory where
|
|
104
|
+
the photo should be located """
|
|
105
|
+
|
|
106
|
+
# Get the EXIF data
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
photo = Image.open(source_file)
|
|
110
|
+
except (OSError, UnidentifiedImageError):
|
|
111
|
+
colour.write(f'[RED:ERROR]: [BLUE:{source_file}] does not appear to be a valid image - ignoring EXIF data')
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
exif = photo.getexif()
|
|
115
|
+
|
|
116
|
+
# Search for the original date/time tag
|
|
117
|
+
|
|
118
|
+
for tag_id in exif:
|
|
119
|
+
tag = TAGS.get(tag_id, tag_id)
|
|
120
|
+
|
|
121
|
+
if tag == 'DateTimeOriginal':
|
|
122
|
+
data = exif.get(tag_id)
|
|
123
|
+
if isinstance(data, bytes):
|
|
124
|
+
data = data.decode()
|
|
125
|
+
|
|
126
|
+
# Ignore dummy value
|
|
127
|
+
|
|
128
|
+
if data.startswith('0000:00:00'):
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
# Convert to YYYY-MM-DD format, removing the time
|
|
132
|
+
|
|
133
|
+
date = f'{int(data[0:4]):04}-{int(data[5:7]):02}-{int(data[8:10]):02}'
|
|
134
|
+
|
|
135
|
+
return date
|
|
136
|
+
|
|
137
|
+
# No date tag found
|
|
138
|
+
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
################################################################################
|
|
142
|
+
|
|
143
|
+
def fix_file(args, source_file):
|
|
144
|
+
""" Fix a file by moving or renaming it to fix naming or directory issues """
|
|
145
|
+
|
|
146
|
+
# Get the image date from the EXIF data
|
|
147
|
+
|
|
148
|
+
image_date = get_exif_date(source_file)
|
|
149
|
+
|
|
150
|
+
# If the file starts with $, ~, _ or ., rename it to remove it
|
|
151
|
+
|
|
152
|
+
while source_file.name[0] in ('$', '~', '_', '.'):
|
|
153
|
+
new_name = source_file.with_name(source_file.name[1:])
|
|
154
|
+
|
|
155
|
+
source_file = safe_rename(args, source_file, new_name)
|
|
156
|
+
|
|
157
|
+
# If filename contains '~' then truncate it
|
|
158
|
+
|
|
159
|
+
if '~' in source_file.name:
|
|
160
|
+
new_name = source_file.with_name(source_file.name.split('~')[0] + source_file.suffix)
|
|
161
|
+
|
|
162
|
+
source_file = safe_rename(args, source_file, new_name)
|
|
163
|
+
|
|
164
|
+
# If the directory name starts with . or $ move the file up 1 level
|
|
165
|
+
|
|
166
|
+
while source_file.parts[-2][0] in ('$', '.'):
|
|
167
|
+
new_name = source_file.parent.parent / source_file.name
|
|
168
|
+
|
|
169
|
+
source_file = safe_rename(args, source_file, new_name)
|
|
170
|
+
|
|
171
|
+
# If the filename has a number in parentheses, then remove it
|
|
172
|
+
|
|
173
|
+
num_match = NUMBER_RE.fullmatch(source_file.stem)
|
|
174
|
+
if num_match:
|
|
175
|
+
new_name = source_file.parent / (num_match.group(1) + source_file.suffix)
|
|
176
|
+
|
|
177
|
+
source_file = safe_rename(args, source_file, new_name)
|
|
178
|
+
|
|
179
|
+
# See if the date in the EXIF data matches the directory name prefix
|
|
180
|
+
# and move it to the correct location if it doesn't
|
|
181
|
+
|
|
182
|
+
if image_date:
|
|
183
|
+
image_year = image_date.split('-')[0]
|
|
184
|
+
|
|
185
|
+
image_path = args.path / image_year / image_date
|
|
186
|
+
|
|
187
|
+
# If the file isn't already in a directory with the correct year and date
|
|
188
|
+
# move it to one that it
|
|
189
|
+
|
|
190
|
+
if not str(source_file.parent).startswith(str(image_path)):
|
|
191
|
+
# If the source directory has a description after the date, append that
|
|
192
|
+
# to the destination directory
|
|
193
|
+
# Otherwise, if the source directory doesn't have a date, append the whole
|
|
194
|
+
# directory name.
|
|
195
|
+
|
|
196
|
+
source_parent_dir = source_file.parts[-2]
|
|
197
|
+
|
|
198
|
+
if DATE_RE.match(source_parent_dir):
|
|
199
|
+
if len(source_parent_dir) > 10:
|
|
200
|
+
image_path = args.path / image_year / f'{image_date}{source_parent_dir[10:]}'
|
|
201
|
+
else:
|
|
202
|
+
image_path = args.path / image_year / f'{image_date} - {source_parent_dir}'
|
|
203
|
+
|
|
204
|
+
source_file = safe_rename(args, source_file, image_path / source_file.name)
|
|
205
|
+
|
|
206
|
+
################################################################################
|
|
207
|
+
|
|
208
|
+
def main():
|
|
209
|
+
""" Entry point """
|
|
210
|
+
|
|
211
|
+
args = parse_command_line()
|
|
212
|
+
|
|
213
|
+
# Disable the maximum image size in PIL
|
|
214
|
+
|
|
215
|
+
Image.MAX_IMAGE_PIXELS = None
|
|
216
|
+
|
|
217
|
+
# Find matching files in the source tree
|
|
218
|
+
|
|
219
|
+
print(f'Searching {args.path} with extension matching {", ".join(FILE_TYPES)}')
|
|
220
|
+
|
|
221
|
+
all_matches = args.path.glob('**/*')
|
|
222
|
+
|
|
223
|
+
matches = [file for file in all_matches if file.suffix.lower() in FILE_TYPES and file.is_file()]
|
|
224
|
+
|
|
225
|
+
print(f'Found {len(matches)} matching files')
|
|
226
|
+
|
|
227
|
+
for source_file in matches:
|
|
228
|
+
if '.stversions' not in source_file.parts:
|
|
229
|
+
fix_file(args, source_file)
|
|
230
|
+
|
|
231
|
+
################################################################################
|
|
232
|
+
|
|
233
|
+
def phototidier():
|
|
234
|
+
"""Entry point"""
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
main()
|
|
238
|
+
|
|
239
|
+
except KeyboardInterrupt:
|
|
240
|
+
sys.exit(1)
|
|
241
|
+
|
|
242
|
+
except BrokenPipeError:
|
|
243
|
+
sys.exit(2)
|
|
244
|
+
|
|
245
|
+
################################################################################
|
|
246
|
+
|
|
247
|
+
if __name__ == '__main__':
|
|
248
|
+
phototidier()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
################################################################################
|
|
2
|
+
""" Curses-based pop-up message
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
with PopUp(curses_screen, message, colour):
|
|
6
|
+
do_stuff
|
|
7
|
+
|
|
8
|
+
Popup message is displayed for the duration of the with statement, and
|
|
9
|
+
has optional parameters to wait for a keypress, and/or pause before removing
|
|
10
|
+
the popup again.
|
|
11
|
+
|
|
12
|
+
"""
|
|
13
|
+
################################################################################
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
import curses
|
|
17
|
+
|
|
18
|
+
################################################################################
|
|
19
|
+
|
|
20
|
+
class PopUp():
|
|
21
|
+
""" Class to enable popup windows to be used via with statements """
|
|
22
|
+
|
|
23
|
+
def __init__(self, screen, msg, colour, waitkey=False, sleep=True, centre=True, refresh=True):
|
|
24
|
+
""" Initialisation - just save the popup parameters """
|
|
25
|
+
|
|
26
|
+
self.panel = None
|
|
27
|
+
self.screen = screen
|
|
28
|
+
self.msg = msg
|
|
29
|
+
self.centre = centre
|
|
30
|
+
self.colour = curses.color_pair(colour)
|
|
31
|
+
self.refresh = refresh
|
|
32
|
+
self.sleep = sleep and not waitkey
|
|
33
|
+
self.waitkey = waitkey
|
|
34
|
+
self.start_time = 0
|
|
35
|
+
|
|
36
|
+
def __enter__(self):
|
|
37
|
+
""" Display the popup """
|
|
38
|
+
|
|
39
|
+
lines = self.msg.split('\n')
|
|
40
|
+
height = len(lines)
|
|
41
|
+
|
|
42
|
+
width = 0
|
|
43
|
+
for line in lines:
|
|
44
|
+
width = max(width, len(line))
|
|
45
|
+
|
|
46
|
+
width += 2
|
|
47
|
+
height += 2
|
|
48
|
+
|
|
49
|
+
size_y, size_x = self.screen.getmaxyx()
|
|
50
|
+
|
|
51
|
+
window = curses.newwin(height, width, (size_y - height) // 2, (size_x - width) // 2)
|
|
52
|
+
self.panel = curses.panel.new_panel(window)
|
|
53
|
+
|
|
54
|
+
window.bkgd(' ', self.colour)
|
|
55
|
+
for y_pos, line in enumerate(lines):
|
|
56
|
+
x_pos = (width - len(line)) // 2 if self.centre else 1
|
|
57
|
+
window.addstr(y_pos + 1, x_pos, line, self.colour)
|
|
58
|
+
|
|
59
|
+
self.panel.top()
|
|
60
|
+
curses.panel.update_panels()
|
|
61
|
+
self.screen.refresh()
|
|
62
|
+
|
|
63
|
+
self.start_time = time.monotonic()
|
|
64
|
+
|
|
65
|
+
if self.waitkey:
|
|
66
|
+
while True:
|
|
67
|
+
keypress = self.screen.getch()
|
|
68
|
+
if keypress != curses.KEY_RESIZE:
|
|
69
|
+
break
|
|
70
|
+
|
|
71
|
+
curses.panel.update_panels()
|
|
72
|
+
self.screen.refresh()
|
|
73
|
+
|
|
74
|
+
def __exit__(self, exc_type, exc_value, exc_traceback):
|
|
75
|
+
""" Remove the popup """
|
|
76
|
+
|
|
77
|
+
if self.panel:
|
|
78
|
+
if self.sleep:
|
|
79
|
+
elapsed = time.monotonic() - self.start_time
|
|
80
|
+
|
|
81
|
+
if elapsed < 1:
|
|
82
|
+
time.sleep(1 - elapsed)
|
|
83
|
+
|
|
84
|
+
del self.panel
|
|
85
|
+
|
|
86
|
+
if self.refresh:
|
|
87
|
+
self.screen.refresh()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#! /usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
################################################################################
|
|
4
|
+
""" Basic subprocess handling - simplified API to the Python subprocess module
|
|
5
|
+
|
|
6
|
+
Copyright (C) 2017-18 John Skilleter
|
|
7
|
+
|
|
8
|
+
Licence: GPL v3 or later
|
|
9
|
+
"""
|
|
10
|
+
################################################################################
|
|
11
|
+
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from skilleter_thingy import logger
|
|
16
|
+
|
|
17
|
+
################################################################################
|
|
18
|
+
|
|
19
|
+
class RunError(Exception):
|
|
20
|
+
""" Run exception """
|
|
21
|
+
|
|
22
|
+
def __init__(self, msg, status=1):
|
|
23
|
+
super(RunError, self).__init__(msg)
|
|
24
|
+
self.msg = msg
|
|
25
|
+
self.status = status
|
|
26
|
+
|
|
27
|
+
################################################################################
|
|
28
|
+
|
|
29
|
+
def run(command, foreground=False, shell=False):
|
|
30
|
+
""" Run the specified command and return the output.
|
|
31
|
+
command - the command to run as an array of command+arguments
|
|
32
|
+
foreground - set to True to run the command in the foreground, using stdin/err/out
|
|
33
|
+
shell - set to True to run the command inside a shell (allows the command to be specified
|
|
34
|
+
as a string, but needs spaces to bew quoted
|
|
35
|
+
Returns an empty array if foreground is True or an array of the output otherwise. """
|
|
36
|
+
|
|
37
|
+
# TODO: for background use subprocess.Popen but use devnull = open('/dev/null', 'w') for stdio and return proc instead of communicating with it?
|
|
38
|
+
|
|
39
|
+
log.info('Running "%s"', ' '.join(command))
|
|
40
|
+
|
|
41
|
+
# If running in the foreground, run the command and either return an empty value
|
|
42
|
+
# on success (output is to the console) or raise a RunError
|
|
43
|
+
|
|
44
|
+
if foreground:
|
|
45
|
+
try:
|
|
46
|
+
if shell:
|
|
47
|
+
# TODO: Handle command lines with parameters containing spaces
|
|
48
|
+
command = ' '.join(command)
|
|
49
|
+
|
|
50
|
+
status = subprocess.run(command, shell=shell).returncode
|
|
51
|
+
if status:
|
|
52
|
+
raise RunError('Error %d' % status, status)
|
|
53
|
+
else:
|
|
54
|
+
return []
|
|
55
|
+
except OSError as exc:
|
|
56
|
+
raise RunError(exc)
|
|
57
|
+
else:
|
|
58
|
+
# Run the command and capture stdout and stderr
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
proc = subprocess.run(command, stderr=subprocess.PIPE, stdout=subprocess.PIPE, universal_newlines=True)
|
|
62
|
+
except OSError as exc:
|
|
63
|
+
raise RunError(exc)
|
|
64
|
+
|
|
65
|
+
log.info('Stdout: %s', proc.stdout)
|
|
66
|
+
log.info('Stderr: %s', proc.stderr)
|
|
67
|
+
|
|
68
|
+
# If it returned an error raise a RunError exception with the stdout text as the
|
|
69
|
+
# exception message
|
|
70
|
+
|
|
71
|
+
if proc.returncode:
|
|
72
|
+
raise RunError(proc.stderr)
|
|
73
|
+
|
|
74
|
+
# Otherwise return the stdout data or nothing
|
|
75
|
+
|
|
76
|
+
if proc.stdout:
|
|
77
|
+
output = proc.stdout.split('\n')
|
|
78
|
+
else:
|
|
79
|
+
output = []
|
|
80
|
+
|
|
81
|
+
# Remove trailing blank lines from the output
|
|
82
|
+
|
|
83
|
+
while output and output[-1] == '':
|
|
84
|
+
output = output[:-1]
|
|
85
|
+
|
|
86
|
+
log.info('Output: %s', output)
|
|
87
|
+
return output
|
|
88
|
+
|
|
89
|
+
################################################################################
|
|
90
|
+
|
|
91
|
+
log = logger.init('tgy_process')
|
|
92
|
+
|
|
93
|
+
if __name__ == '__main__':
|
|
94
|
+
print('Run ls -l:')
|
|
95
|
+
|
|
96
|
+
cmd_output = run(['ls', '-l'])
|
|
97
|
+
|
|
98
|
+
for o in cmd_output:
|
|
99
|
+
print(o)
|
|
100
|
+
|
|
101
|
+
print('Run wombat (should fail):')
|
|
102
|
+
try:
|
|
103
|
+
run(['wombat'])
|
|
104
|
+
except RunError as exc:
|
|
105
|
+
print('Failed with error: %s' % exc.msg)
|
|
106
|
+
|
|
107
|
+
if sys.stdout.isatty():
|
|
108
|
+
print('Run vi in the foreground')
|
|
109
|
+
|
|
110
|
+
run(['vi'], foreground=True)
|
|
111
|
+
else:
|
|
112
|
+
print('Not testing call to run() with foreground=True as stdout is not a TTY')
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
""" Query api.osv.dev to determine whether a specified version of a particular
|
|
4
|
+
Python package is subject to known security vulnerabilities """
|
|
5
|
+
|
|
6
|
+
################################################################################
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import requests
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
import re
|
|
14
|
+
import glob
|
|
15
|
+
import argparse
|
|
16
|
+
|
|
17
|
+
################################################################################
|
|
18
|
+
|
|
19
|
+
PIP_PACKAGES = ('pip', 'pkg_resources', 'setuptools')
|
|
20
|
+
PIP_OPTIONS = '--no-cache-dir'
|
|
21
|
+
|
|
22
|
+
QUERY_URL = "https://api.osv.dev/v1/query"
|
|
23
|
+
QUERY_HEADERS = {'content-type': 'application/json', 'Accept-Charset': 'UTF-8'}
|
|
24
|
+
|
|
25
|
+
ANSI_NORMAL = '\x1b[0m'
|
|
26
|
+
ANSI_BOLD = '\x1b[1m'
|
|
27
|
+
|
|
28
|
+
################################################################################
|
|
29
|
+
|
|
30
|
+
def audit(package, version):
|
|
31
|
+
""" Main function """
|
|
32
|
+
|
|
33
|
+
# Query api.osv.dev for known vulnerabilties in this version of the package
|
|
34
|
+
|
|
35
|
+
payload = '{"version": "'+version+'", "package": {"name": "'+package+'", "ecosystem": "PyPI"}}'
|
|
36
|
+
result = requests.post(QUERY_URL, data=payload, headers=QUERY_HEADERS)
|
|
37
|
+
|
|
38
|
+
# Parse and report the results
|
|
39
|
+
|
|
40
|
+
details = result.json()
|
|
41
|
+
|
|
42
|
+
print('-' * 80)
|
|
43
|
+
if package in PIP_PACKAGES:
|
|
44
|
+
print(f'Package: {package} {version} (part of Pip)')
|
|
45
|
+
else:
|
|
46
|
+
print(f'Package: {package} {version}')
|
|
47
|
+
|
|
48
|
+
print()
|
|
49
|
+
|
|
50
|
+
if 'vulns' in details:
|
|
51
|
+
print(f'{len(details["vulns"])} known vulnerabilities')
|
|
52
|
+
|
|
53
|
+
for v in details['vulns']:
|
|
54
|
+
print()
|
|
55
|
+
print(f'{ANSI_BOLD}Vulnerability: {v["id"]}{ANSI_NORMAL}')
|
|
56
|
+
|
|
57
|
+
if 'summary' in v:
|
|
58
|
+
print(f'Summary: {v["summary"]}')
|
|
59
|
+
|
|
60
|
+
if 'aliases' in v:
|
|
61
|
+
print('Aliases: %s' % (', '.join(v['aliases'])))
|
|
62
|
+
|
|
63
|
+
if 'details' in v:
|
|
64
|
+
print()
|
|
65
|
+
print(v['details'])
|
|
66
|
+
else:
|
|
67
|
+
print(f'No known vulnerabilities')
|
|
68
|
+
|
|
69
|
+
################################################################################
|
|
70
|
+
|
|
71
|
+
def main():
|
|
72
|
+
""" Entry point """
|
|
73
|
+
|
|
74
|
+
parser = argparse.ArgumentParser(description='Query api.osv.dev to determine whether Python packagers in a requirments.txt file are subject to known security vulnerabilities')
|
|
75
|
+
parser.add_argument('requirements', nargs='*', type=str, action='store', help='The requirements file (if not specified, then the script searches for a requirements.txt file)')
|
|
76
|
+
args = parser.parse_args()
|
|
77
|
+
|
|
78
|
+
requirements = args.requirements or glob.glob('**/requirements.txt', recursive=True)
|
|
79
|
+
|
|
80
|
+
if not requirements:
|
|
81
|
+
print('No requirements.txt file(s) found')
|
|
82
|
+
sys.exit(0)
|
|
83
|
+
|
|
84
|
+
# Create a venv for each requirements file, install pip and the packages
|
|
85
|
+
# and prerequisites, get the list of installed package versions
|
|
86
|
+
# and check each one.
|
|
87
|
+
|
|
88
|
+
for requirement in requirements:
|
|
89
|
+
print('=' * 80)
|
|
90
|
+
print(f'{ANSI_BOLD}File: {requirement}{ANSI_NORMAL}')
|
|
91
|
+
print()
|
|
92
|
+
|
|
93
|
+
with tempfile.TemporaryDirectory() as env_dir:
|
|
94
|
+
with tempfile.NamedTemporaryFile() as package_list:
|
|
95
|
+
script = f'python3 -m venv {env_dir}' \
|
|
96
|
+
f' && . {env_dir}/bin/activate' \
|
|
97
|
+
f' && python3 -m pip {PIP_OPTIONS} install --upgrade pip setuptools' \
|
|
98
|
+
f' && python3 -m pip {PIP_OPTIONS} install -r {requirement}' \
|
|
99
|
+
f' && python3 -m pip {PIP_OPTIONS} list | tail -n+3 | tee {package_list.name}' \
|
|
100
|
+
' && deactivate'
|
|
101
|
+
|
|
102
|
+
result = subprocess.run(script, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True)
|
|
103
|
+
|
|
104
|
+
if result.returncode:
|
|
105
|
+
print(f'ERROR #{result.returncode}: {result.stdout}')
|
|
106
|
+
sys.exit(result.returncode)
|
|
107
|
+
|
|
108
|
+
with open(package_list.name) as infile:
|
|
109
|
+
for package in infile.readlines():
|
|
110
|
+
package_info = re.split(' +', package.strip())
|
|
111
|
+
|
|
112
|
+
audit(package_info[0], package_info[1])
|
|
113
|
+
|
|
114
|
+
################################################################################
|
|
115
|
+
|
|
116
|
+
def py_audit():
|
|
117
|
+
"""Entry point"""
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
main()
|
|
121
|
+
|
|
122
|
+
except KeyboardInterrupt:
|
|
123
|
+
sys.exit(1)
|
|
124
|
+
|
|
125
|
+
except BrokenPipeError:
|
|
126
|
+
sys.exit(2)
|
|
127
|
+
|
|
128
|
+
################################################################################
|
|
129
|
+
|
|
130
|
+
if __name__ == '__main__':
|
|
131
|
+
py_audit()
|