Python-File-Tools 0.1.0__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.
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from python_file_tools.file_tools import *
@@ -0,0 +1,456 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import shutil
7
+ import tempfile
8
+ import zipfile
9
+ import html_string_tools
10
+ import python_file_tools.sort as pft_sort
11
+ from os.path import abspath, basename, exists, isdir, join, relpath
12
+ from typing import List
13
+
14
+ def write_text_file(file:str, text:str):
15
+ """
16
+ Writes a file containing the given text.
17
+ Will overwrite existing files
18
+
19
+ :param file: Path of the file to create
20
+ :type file: str, required
21
+ :param text: Text to save in the file
22
+ :type text: str, required
23
+ """
24
+ try:
25
+ full_path = abspath(file)
26
+ with open(full_path, "w", encoding="UTF-8") as out_file:
27
+ out_file.write(text)
28
+ except FileNotFoundError: pass
29
+
30
+ def read_text_file(file:str) -> str:
31
+ """
32
+ Reads the content of a given text file.
33
+
34
+ :param file: Path of the file to read
35
+ :type file: str, required
36
+ :return: Text contained in the given file
37
+ :rtype: str
38
+ """
39
+ encodings = ["utf-8", "ascii", "latin_1", "cp437", "cp500"]
40
+ for encoding in encodings:
41
+ try:
42
+ with open(abspath(file), "rb") as in_file:
43
+ data = in_file.read()
44
+ text = data.decode(encoding)
45
+ return text.strip()
46
+ except: pass
47
+ return None
48
+
49
+ def write_json_file(file:str, contents:dict):
50
+ """
51
+ Writes a JSON file containing the given dictionary as contents.
52
+
53
+ :param file: Path of the file to create
54
+ :type file: str, required
55
+ :param text: Contents to save as JSON
56
+ :type text: str, required
57
+ """
58
+ json_text = json.dumps(contents, indent=" ", separators=(", ", ": "))
59
+ write_text_file(file, json_text)
60
+
61
+ def read_json_file(file:str) -> dict:
62
+ """
63
+ Returns the contents of a given JSON file as a dictionary.
64
+
65
+ :param file: JSON file to read.
66
+ :type file: str, required
67
+ :return: Contents of the JSON file
68
+ :rtype: dict
69
+ """
70
+ try:
71
+ json_text = read_text_file(file)
72
+ json_dict = json.loads(json_text)
73
+ return json_dict
74
+ except(TypeError, json.JSONDecodeError): return {}
75
+
76
+ def find_all_files(directory:str, include_subdirectories:bool=True) -> List[str]:
77
+ """
78
+ Returns a list of all files within a given directory
79
+
80
+ :param directory: Directory in which to search for files
81
+ :type directory: str, required
82
+ :param include_subdirectories: Whether to also search subdirectories for files, defaults to True
83
+ :type include_subdirectories: bool, optional
84
+ :return: List of all files, giving the full file path
85
+ :rtype: list[str]
86
+ """
87
+ # Run through all directories
88
+ files = []
89
+ directories = [abspath(directory)]
90
+ while len(directories) > 0:
91
+ # Get list of all files in the current directory
92
+ current_files = os.listdir(directories[0])
93
+ for filename in current_files:
94
+ # Check if file is a directory
95
+ full_file = abspath(join(directories[0], filename))
96
+ if not isdir(full_file):
97
+ files.append(full_file)
98
+ elif include_subdirectories:
99
+ directories.append(full_file)
100
+ # Delete the current directory from the list
101
+ del directories[0]
102
+ # Return list of files
103
+ return pft_sort.sort_alphanum(files)
104
+
105
+ def find_files_of_type(directory:str, extension:str,
106
+ include_subdirectories:bool=True, inverted:bool=False) -> List[str]:
107
+ """
108
+ Returns a list of files in a given directory that match a given file extension.
109
+
110
+ :param directory: Directory in which to search for files
111
+ :type directory: str, required
112
+ :param extension: File extension(s) to search for
113
+ :type extension: str/List[str], required
114
+ :param include_subdirectories: Whether to also search subdirectories for files, defaults to True
115
+ :type include_subdirectories: bool, optional
116
+ :param inverted: If true, searches for files WITHOUT the given extension, defaults to False
117
+ :type inverted: bool, optional
118
+ :return: List of files that match the extension, giving the full file path
119
+ :rtype: list[str]
120
+ """
121
+ # Get the list of extensions
122
+ extensions = extension
123
+ if isinstance(extensions, str):
124
+ extensions = [extensions.lower()]
125
+ else:
126
+ for i in range(0, len(extensions)):
127
+ extensions[i] = extensions[i].lower()
128
+ # Get list of all files in the given directory
129
+ all_files = find_all_files(directory, include_subdirectories)
130
+ # Find files that match the given extensions
131
+ files = []
132
+ for current_file in all_files:
133
+ current_extension = html_string_tools.get_extension(current_file).lower()
134
+ if not (current_extension in extensions) == inverted:
135
+ files.append(current_file)
136
+ # Return files
137
+ return files
138
+
139
+ def directory_contains(directory:str, extension:List[str], include_subdirectories:bool=True) -> bool:
140
+ """
141
+ Returns whether a given directory contains a file with any of the given extensions.
142
+
143
+ :param directory: Directory in which to search for files
144
+ :type directory: str, required
145
+ :param extensions: List of extensions to search for
146
+ :type extensions: List[str], required
147
+ :param include_subdirectories: Whether to search subdirectories, defaults to True
148
+ :type include_subdirectories: bool, optional
149
+ :return: Whether any files of the given extensions exist in the given directory
150
+ :rtype: bool
151
+ """
152
+ directories = [abspath(directory)]
153
+ # Get the list of extensions
154
+ extensions = extension
155
+ if isinstance(extensions, str):
156
+ extensions = [extensions]
157
+ # Run through all directories
158
+ while len(directories) > 0:
159
+ # Get list of all files in the current directory
160
+ current_files = os.listdir(directories[0])
161
+ for filename in current_files:
162
+ # Get the full file
163
+ full_file = abspath(join(directories[0], filename))
164
+ # Check if the file is a directory
165
+ if isdir(full_file):
166
+ if include_subdirectories:
167
+ directories.append(full_file)
168
+ continue
169
+ # Check if the extension matches
170
+ cur_extension = html_string_tools.get_extension(full_file).lower()
171
+ if cur_extension in extensions:
172
+ return True
173
+ # Delete the current directory from the list
174
+ del directories[0]
175
+ # Return false if no files of the type specified were found
176
+ return False
177
+
178
+ def create_zip(directory:str, zip_path:str, compress_level:int=9, mimetype:str=None) -> bool:
179
+ """
180
+ Creates a zip file with all the files and subdirectories within a given directory.
181
+
182
+ :param directory: Directory with files to archive into a zip file
183
+ :type directory: str, required
184
+ :param zip_path: Path of the zip file to be created
185
+ :type zip_path: str, required
186
+ :param compress_level: Level of compression from min 0 to max 9, defaults to 9
187
+ :type compress_level: int, optional
188
+ :return: Whether a zip file was successfully created
189
+ :param mimetype: Mimetype for the file to be added without compression for zip-based formats, defaults to None
190
+ :type mimetype: str
191
+ :rtype: bool
192
+ """
193
+ # Get list of files in the directory
194
+ full_directory = abspath(directory)
195
+ files = os.listdir(full_directory)
196
+ for i in range(0, len(files)):
197
+ files[i] = abspath(join(full_directory, files[i]))
198
+ # Expand list of files to include subdirectories
199
+ for file in files:
200
+ if isdir(file):
201
+ sub_files = os.listdir(file)
202
+ for i in range(0, len(sub_files)):
203
+ files.append(abspath(join(file, sub_files[i])))
204
+ # Create empty zip file
205
+ with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=compress_level) as out_file:
206
+ if mimetype is not None:
207
+ out_file.writestr("mimetype", mimetype, compress_type=zipfile.ZIP_STORED)
208
+ if not exists(zip_path):
209
+ return False
210
+ # Write contents of directory to zip file
211
+ for file in files:
212
+ if not basename(file).startswith("."):
213
+ relative = relpath(file, full_directory)
214
+ with zipfile.ZipFile(zip_path, "a", compression=zipfile.ZIP_DEFLATED, compresslevel=compress_level) as out_file:
215
+ out_file.write(file, relative)
216
+ # Return if the zip file was successfully created
217
+ return exists(zip_path)
218
+
219
+ def extract_zip(zip_path:str, extract_directory:str, create_folder:bool=False,
220
+ remove_internal:bool=False, delete_files:List[str]=[]) -> bool:
221
+ """
222
+ Extracts a ZIP file into a given directory without ever overwriting files.
223
+
224
+ :param zip_path: Path to ZIP file to extract
225
+ :type zip_path: str, required
226
+ :param extract_directory: Directory in which to extract ZIP contents
227
+ :type extract_directory: str, required
228
+ :param create_folder: Whether to create a folder within the given directory to hold contents, defaults to False
229
+ :type create_folder: bool, optional
230
+ :param remove_internal: Whether to remove redundant internal folder, defaults to False
231
+ :type remove_internal: bool, optional
232
+ :param delete_files: List of filenames to delete if desired, defaults to []
233
+ :type delete_files: list[str], optional
234
+ :return: Whether the files were extracted successfully
235
+ :rtype: bool
236
+ """
237
+ # Get temporary directory
238
+ with tempfile.TemporaryDirectory() as unzip_dir:
239
+ # Unzip files into temp directory
240
+ try:
241
+ with zipfile.ZipFile(zip_path, mode="r") as file:
242
+ file.extractall(path=unzip_dir)
243
+ except (FileNotFoundError, OSError, zipfile.BadZipFile): return False
244
+ # Delete listed files
245
+ for delete_file in delete_files:
246
+ full_file = abspath(join(unzip_dir, delete_file))
247
+ if exists(full_file):
248
+ os.remove(full_file)
249
+ # Remove internal folder if specified
250
+ if remove_internal and len(os.listdir(unzip_dir)) == 1:
251
+ full_file = abspath(join(unzip_dir, os.listdir(unzip_dir)[0]))
252
+ if isdir(full_file):
253
+ unzip_dir = full_file
254
+ # Check if any of the to be extracted files already exist in the new directory
255
+ contains_existing = create_folder
256
+ files = os.listdir(unzip_dir)
257
+ for current_file in files:
258
+ if contains_existing:
259
+ break
260
+ contains_existing = exists(join(extract_directory, current_file))
261
+ # Create new extraction subfolder if specified
262
+ main_dir = abspath(extract_directory)
263
+ new_dir = abspath(extract_directory)
264
+ if create_folder or contains_existing:
265
+ filename = basename(zip_path)
266
+ extension = html_string_tools.get_extension(filename)
267
+ filename = filename[:len(filename) - len(extension)]
268
+ filename = get_available_filename(["AAAAAAAAAA"], filename, main_dir)
269
+ new_dir = abspath(join(main_dir, filename))
270
+ os.mkdir(new_dir)
271
+ # Copy files to new directory
272
+ files = os.listdir(unzip_dir)
273
+ for current_file in files:
274
+ extension = html_string_tools.get_extension(current_file)
275
+ filename = current_file[:len(current_file) - len(extension)]
276
+ filename = get_available_filename([current_file], filename, new_dir)
277
+ old_file = abspath(join(unzip_dir, current_file))
278
+ new_file = abspath(join(new_dir, f"{filename}{extension}"))
279
+ if isdir(old_file):
280
+ shutil.copytree(old_file, new_file)
281
+ else:
282
+ shutil.copy(old_file, new_file)
283
+ return True
284
+
285
+ def extract_file_from_zip(zip_path:str, extract_directory:str, extract_file:str, check_subdirectories:bool=False) -> str:
286
+ """
287
+ Attempts to extract a single file from a ZIP archive given a filename.
288
+
289
+ :param zip_path: ZIP archive to extract a file from.
290
+ :type zip_path: str, required
291
+ :param extract_directory: Directory in which to extract the file.
292
+ :type extract_directory: str, required
293
+ :param extract_file: Filename of the file to be extracted
294
+ :type extract_file: str, required
295
+ :param check_subdirectories: Whether to check subdirectories for the given file as well, defaults to False
296
+ :type check_subdirectories: bool, optional
297
+ :return: Path of the extracted file, None if file couldn't be extracted
298
+ :rtype: str
299
+ """
300
+ try:
301
+ # Get temporary directory
302
+ with tempfile.TemporaryDirectory() as temp_dir:
303
+ with zipfile.ZipFile(zip_path, mode="r") as zfile:
304
+ # Get the correct file to extract
305
+ internal_file = None
306
+ info_list = zfile.infolist()
307
+ for info in info_list:
308
+ if info.filename == extract_file or (check_subdirectories and basename(info.filename) == extract_file):
309
+ internal_file = info.filename
310
+ # Attempt extracting the file
311
+ zfile.extract(internal_file, path=temp_dir)
312
+ extracted = abspath(join(temp_dir, internal_file))
313
+ new_file = abspath(join(extract_directory, extract_file))
314
+ # Update file if it already exists
315
+ if exists(new_file):
316
+ extension = html_string_tools.get_extension(extract_file)
317
+ filename = extract_file[:len(extract_file) - len(extension)]
318
+ filename = get_available_filename([extracted], filename, extract_directory)
319
+ new_file = abspath(join(extract_directory, f"{filename}{extension}"))
320
+ # Copy file to new location
321
+ shutil.copy(extracted, new_file)
322
+ return new_file
323
+ except (zipfile.BadZipFile, FileNotFoundError, OSError, KeyError, zipfile.BadZipFile): return None
324
+
325
+ def get_file_friendly_text(string:str, ascii_only:bool=False) -> str:
326
+ """
327
+ Creates a string suitable for a filename from a given string.
328
+
329
+ :param string: Any string to convert into filename
330
+ :type string: str, required
331
+ :param ascii_only: Whether to only allow basic ASCII characters, defaults to False
332
+ :type ascii_only: bool, optional
333
+ :return: String with all invalid characters removed or replaced
334
+ :rtype: str
335
+ """
336
+ # Return default string if the whole name is disallowed
337
+ reserved = "^con$|^prn$|^aux$|^nul$|^com[1-5]$|^lpt[1-5]$"
338
+ if string is None or len(re.findall(reserved, string.lower())) > 0:
339
+ return "0"
340
+ # Unify hyphen and whitespace varieties
341
+ new_string = re.sub(r"\s", " ", string)
342
+ new_string = re.sub(r"[\--﹣‑‐⎼]", "-", new_string)
343
+ # Replace special structures
344
+ new_string = re.sub(r":", " - ", new_string)
345
+ new_string = re.sub(r"\s+\-+>\s+", " to ", new_string)
346
+ new_string = re.sub(r"(?:\.\s*){2}\.", "…", new_string)
347
+ # Remove invalid filename characters
348
+ new_string = re.sub(r'[<>"\\\/\|\*\?]', "-", new_string)
349
+ new_string = re.sub(r"[\x00-\x1F]|(?:\s*\.\s*)+$", "", new_string)
350
+ # Replace repeated hyphens and whitespace
351
+ new_string = re.sub(r"\-+(?:\s*\-+)*", "-", new_string)
352
+ new_string = re.sub(r"\s+", " ", new_string)
353
+ # Remove hanging hyphens
354
+ new_string = re.sub(r"(?<=[^\s])-(?=\s)|(?<=\s)-(?=[^\s])", "", new_string)
355
+ # Remove whitespace and hyphens from the end of string
356
+ new_string = re.sub(r"^[\s\-]+|[\s\-]+$", "", new_string)
357
+ # Replace non-standard ASCII characters, if specified
358
+ if ascii_only:
359
+ new_string = re.sub(r"[ÀÁÂÃÄÅ]", "A", new_string)
360
+ new_string = re.sub(r"[ÈÉÊË]", "E", new_string)
361
+ new_string = re.sub(r"[ÌÍÎÏ]", "I", new_string)
362
+ new_string = re.sub(r"[ÒÓÔÕÖ]", "O", new_string)
363
+ new_string = re.sub(r"[ÙÚÛÜ]", "U", new_string)
364
+ new_string = re.sub(r"[ÑŃ]", "N", new_string)
365
+ new_string = re.sub(r"[ÝŸ]", "Y", new_string)
366
+ new_string = re.sub(r"[àáâãäå]", "a", new_string)
367
+ new_string = re.sub(r"[èéêë]", "e", new_string)
368
+ new_string = re.sub(r"[ìíîï]", "i", new_string)
369
+ new_string = re.sub(r"[òóôõö]", "o", new_string)
370
+ new_string = re.sub(r"[ùúûü]", "u", new_string)
371
+ new_string = re.sub(r"[ńñ]", "n", new_string)
372
+ new_string = re.sub(r"[ýÿ]", "y", new_string)
373
+ regex = r"[\.\x22-\x27\x2A-\x2F\x3A-\x40\x5E-\x60]|[^\x20-\x7A]"
374
+ new_string = re.sub(regex, "-", new_string)
375
+ return get_file_friendly_text(new_string, False)
376
+ # Check if the string is empty
377
+ if new_string == "":
378
+ return "0"
379
+ # Return the modified string
380
+ return new_string
381
+
382
+ def get_available_filename(source_files:List[str], filename:str, end_path:str, ascii_only:bool=False) -> str:
383
+ """
384
+ Returns a filename not already taken in a given directory.
385
+ The given disired filename will be slightly modified if already taken.
386
+
387
+ :param source_files: File(s) with extensions to use when checking for existing files
388
+ :type source_files: List[str]/str, required
389
+ :param filename: The desired filename (without extension)
390
+ :type filename: str, required
391
+ :param end_path: The path of the directory with files to check against
392
+ :type end_path: str, required
393
+ :param ascii_only: Whether to only allow basic ASCII characters in the filename, defaults to False
394
+ :type ascii_only:bool, optional
395
+ :return: Filename that is available to be used in the given directory
396
+ :rtype: str
397
+ """
398
+ # Get the file friendly version of the desired filename
399
+ new_filename = get_file_friendly_text(filename, ascii_only)
400
+ # Get extensions from the source files
401
+ extensions = []
402
+ if isinstance(source_files, list):
403
+ for source_file in source_files:
404
+ extensions.append(html_string_tools.get_extension(source_file))
405
+ else:
406
+ extensions = [html_string_tools.get_extension(source_files)]
407
+ # Get a list of all the files in the end path
408
+ try:
409
+ files = []
410
+ for file in os.listdir(abspath(end_path)):
411
+ files.append(file.lower())
412
+ except FileNotFoundError:
413
+ return None
414
+ # Get the new filename that is available
415
+ base = new_filename
416
+ append_num = 1
417
+ while True:
418
+ try:
419
+ for extension in extensions:
420
+ assert not (f"{new_filename}{extension}").lower() in files
421
+ return new_filename
422
+ except AssertionError:
423
+ append_num += 1
424
+ new_filename = f"{base}-{append_num}"
425
+
426
+ def rename_file(file:str, new_filename:str, ascii_only:bool=False) -> str:
427
+ """
428
+ Renames a given file to a given filename.
429
+ Number will be appended to filename if file already exists with that name.
430
+
431
+ :param file: Path of the file to rename
432
+ :type file: str, required
433
+ :param new_filename: Filename to set new file to
434
+ :type new_filename: str, required
435
+ :param ascii_only: Whether to only allow basic ASCII characters in the filename, defaults to False
436
+ :type ascii_only:bool, optional
437
+ :return: Path of the file after being renamed, None if rename failed
438
+ :rtype: str
439
+ """
440
+ # Get the prefered new filename
441
+ path = abspath(file)
442
+ extension = html_string_tools.get_extension(file)
443
+ filename = get_file_friendly_text(new_filename, ascii_only)
444
+ # Do nothing if the filename is already accurate
445
+ if basename(path) == f"{filename}{extension}":
446
+ return path
447
+ # Update filename if file already exists
448
+ parent_dir = abspath(join(path, os.pardir))
449
+ filename = get_available_filename([file], new_filename, parent_dir, ascii_only)
450
+ # Rename file
451
+ try:
452
+ new_file = abspath(join(parent_dir, f"{filename}{extension}"))
453
+ os.rename(path, new_file)
454
+ return new_file
455
+ except FileNotFoundError:
456
+ return None
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import re
4
+ import _functools
5
+ from typing import List
6
+
7
+ def get_first_section(string:str) -> str:
8
+ """
9
+ Gets the first "section" of a given string.
10
+ A section should contain either only text or only a number.
11
+
12
+ :param string: String to get section from
13
+ :type string: str, required
14
+ :return: The first "section" of the given string
15
+ :rtype: str
16
+ """
17
+ # Check if string starts with a number
18
+ if len(re.findall("^[0-9]", string)) > 0:
19
+ # Return number section if string starts with a number
20
+ return re.findall(r"[0-9]+[0-9,\.]*", string)[0]
21
+ # Return non-number section if string doesn't start with number
22
+ sections = re.findall("[^0-9]+", string)
23
+ if len(sections) > 0:
24
+ return sections[0]
25
+ # Return empty string if no sections could be found
26
+ return ""
27
+
28
+ def compare_sections(section1:str, section2:str) -> int:
29
+ """
30
+ Compares two sections alphanumerically.
31
+ Returns -1 if section1 comes first, 1 if section2 comes first.
32
+ Returns 0 if section1 and section2 are identical.
33
+
34
+ :param section1: First section to compare
35
+ :type section1: str, required
36
+ :param section2: Second section to compare
37
+ :type section2: str, required
38
+ :return: Integer describing which section should come first
39
+ :rtype: int
40
+ """
41
+ try:
42
+ # Compare numbers, if applicable.
43
+ float1 = float(section1.replace(",", ""))
44
+ float2 = float(section2.replace(",", ""))
45
+ if float1 > float2:
46
+ return 1
47
+ elif float1 < float2:
48
+ return -1
49
+ return 0
50
+ except ValueError:
51
+ # Return 0 if sections are identical
52
+ if section1 == section2:
53
+ return 0
54
+ # Compare text values
55
+ sort = sorted([section1, section2])
56
+ if sort[0] == section1:
57
+ return -1
58
+ return 1
59
+
60
+ def compare_alphanum(string1:str, string2:str) -> int:
61
+ """
62
+ Compares two strings alphanumerically.
63
+ Returns -1 if string1 comes first, 1 if string2 comes first.
64
+ Returns 0 if string1 and string2 are identical.
65
+
66
+ :param string1: First string to compare
67
+ :type string1: str, required
68
+ :param string2: Second string to compare
69
+ :type string2: str, required
70
+ :return: Integer describing which string should come first
71
+ :rtype: int
72
+ """
73
+ # Prepare strings for comparison
74
+ compare = 0
75
+ left1 = re.sub(r"\s+", " ", string1.lower()).strip()
76
+ left2 = re.sub(r"\s+", " ", string2.lower()).strip()
77
+ # Run through comparing strings section by section
78
+ while compare == 0 and not left1 == "" and not left2 == "":
79
+ # Get the first sections
80
+ section1 = get_first_section(left1)
81
+ section2 = get_first_section(left2)
82
+ # Modify what's left of strings
83
+ left1 = left1[len(section1):]
84
+ left2 = left2[len(section2):]
85
+ # Compare sections
86
+ compare = compare_sections(section1, section2)
87
+ # Compare full strings if comparing by section inconclusive
88
+ if compare == 0:
89
+ compare = compare_sections(f"T{string1}", f"T{string2}")
90
+ # Return result
91
+ return compare
92
+
93
+ def sort_alphanum(lst:List[str]) -> List[str]:
94
+ """
95
+ Sorts a given list alphanumerically.
96
+
97
+ :param lst: List to sort
98
+ :type lst: list[str], required
99
+ :return: Sorted list
100
+ :rtype: list[str]
101
+ """
102
+ comparator = _functools.cmp_to_key(compare_alphanum)
103
+ return sorted(lst, key=comparator)
104
+
105
+ def get_value_from_dictionary(dictionary:dict, key_list:List):
106
+ """
107
+ Returns a value from a dictionary based on a list of keys.
108
+ If there is only one key, it will get the dictionary value as standard.
109
+ If there are multiple keys, the nested dictionary will be returned.
110
+ Keys can be either a named key or an index value for an array.
111
+
112
+ :param dictionary: Dictionary or List to get the value from
113
+ :type dictionary: dict/List, required
114
+ :param key_list: List of keys/index values
115
+ :type key_list: List, required
116
+ :return: Value of the nested key
117
+ :rtype: any
118
+ """
119
+ try:
120
+ # Get the base value from the dictionary
121
+ value = dictionary[key_list[0]]
122
+ # Return the value if base value is all that's needed
123
+ if len(key_list) == 1:
124
+ return value
125
+ # Get the next value recursively, if required
126
+ return get_value_from_dictionary(value, key_list[1:])
127
+ except (IndexError, KeyError, TypeError): return None
128
+
129
+ def compare_dictionaries_alphanum(dict1:dict, dict2:dict) -> int:
130
+ """
131
+ Compares two dictionaries alphanumerically
132
+ Returns -1 if dict1 comes first, 1 if dict2 comes first.
133
+ Returns 0 if dict1 and dict2 values are identical.
134
+
135
+ Dictionaries are compared by the values of a given key list.
136
+ The key list should be stored as a value in the dictionary under the key "dvk_alpha_sort_key"
137
+
138
+ :param dict1: First dictionary to compare
139
+ :type dict1: dict, required
140
+ :param dict2: Second dictionary to compare
141
+ :type dict2: str, required
142
+ :return: Integer describing which dictionary should come first
143
+ :rtype: int
144
+ """
145
+ # Get the key to search for
146
+ key = dict1["dvk_alpha_sort_key"]
147
+ if isinstance(key, str):
148
+ key = [key]
149
+ # Get the string values
150
+ string1 = get_value_from_dictionary(dict1, key)
151
+ string2 = get_value_from_dictionary(dict2, key)
152
+ # Return the string comparison
153
+ return compare_alphanum(str(string1), str(string2))
154
+
155
+ def sort_dictionaries_alphanum(lst:List[dict], key_list:List) -> List[dict]:
156
+ """
157
+ Sorts a given list of dictionaries alphanumerically.
158
+ Comparisons are made based on the value of the nested key given in the key list.
159
+
160
+ :param lst: List to sort
161
+ :type lst: list[str], required
162
+ :param key_list: List of keys/index values
163
+ :type key_list: List, required
164
+ :return: Sorted list
165
+ :rtype: List[dict]
166
+ """
167
+ # Add the key list to each dictionary
168
+ dict_list = []
169
+ for item in lst:
170
+ dictionary = item
171
+ dictionary["dvk_alpha_sort_key"] = key_list
172
+ dict_list.append(dictionary)
173
+ # Sort the list of dictionaries
174
+ comparator = _functools.cmp_to_key(compare_dictionaries_alphanum)
175
+ dict_list = sorted(dict_list, key=comparator)
176
+ # Remove the key list from each dict
177
+ for item in dict_list:
178
+ item.pop("dvk_alpha_sort_key")
179
+ # Return the sorted list
180
+ return dict_list
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import os
4
+ from os.path import abspath, join
5
+
6
+ TEST_DIRECTORY = abspath(join(abspath(join(abspath(__file__), os.pardir)), "test_files"))
7
+ BASIC_DIRECTORY = abspath(join(TEST_DIRECTORY, "basic_files"))
8
+ ZIP_CONFLICT_DIRECTORY = abspath(join(TEST_DIRECTORY, "zip_conflicts"))
9
+ MULTI_TYPE_DIRECTORY = abspath(join(TEST_DIRECTORY, "multiple_types"))