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.
- python_file_tools/__init__.py +3 -0
- python_file_tools/file_tools.py +456 -0
- python_file_tools/sort.py +180 -0
- python_file_tools/test/__init__.py +9 -0
- python_file_tools/test/test_file_tools.py +517 -0
- python_file_tools/test/test_sort.py +172 -0
- python_file_tools-0.1.0.dist-info/METADATA +100 -0
- python_file_tools-0.1.0.dist-info/RECORD +11 -0
- python_file_tools-0.1.0.dist-info/WHEEL +5 -0
- python_file_tools-0.1.0.dist-info/licenses/LICENSE +674 -0
- python_file_tools-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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"))
|