Skip to content

Commit

Permalink
Added concurrent's ThreadPoolExecutor support (which speeds up file p…
Browse files Browse the repository at this point in the history
…rocessing) and fixed exceptions handling.
  • Loading branch information
Shararamosh committed Sep 18, 2024
1 parent e989011 commit 5fc7d85
Show file tree
Hide file tree
Showing 3 changed files with 107 additions and 39 deletions.
6 changes: 4 additions & 2 deletions .github/workflows/build_executable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ jobs:
run: pip install pillow tqdm i18nice[YAML] pyinstaller
- name: Run PyInstaller
run: pyinstaller --onefile --name="Pillow_PNG_TGA_Editor" --console --icon=images\Pillows_Hat_Icon.tga --add-data="images:images" --add-data="localization:localization" main.py
- name: Copy Executable from dist to root directory
run: copy dist\Pillow_PNG_TGA_Editor.exe .
- name: Move Executable from dist to root directory
run: |
move dist\Pillow_PNG_TGA_Editor.exe .
rd /s /q dist
shell: cmd
- name: Create Automatic Windows Release
uses: marvinpinto/action-automatic-releases@latest
Expand Down
1 change: 0 additions & 1 deletion CHANGELOG.md

This file was deleted.

139 changes: 103 additions & 36 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@
import locale
import warnings
from tkinter import Tk, filedialog
from concurrent.futures import ThreadPoolExecutor, as_completed
import i18n
import PIL.Image
import PIL.ImageTk
from tqdm import tqdm
from tqdm.contrib.logging import logging_redirect_tqdm
from helper_funcs import resave_img # pylint: disable=import-error

USE_CONCURRENT = True


def get_resource_path(filename: str) -> str:
"""
Expand All @@ -29,6 +32,7 @@ def get_resource_path(filename: str) -> str:

logging.basicConfig(stream=sys.stdout, format="%(message)s", level=logging.INFO)
warnings.filterwarnings("ignore", category=DeprecationWarning)
# noinspection PyDeprecation
i18n.set("locale", locale.getdefaultlocale()[0]) # pylint: disable=deprecated-method
i18n.set("fallback", "en_US")
i18n.load_path.append(get_resource_path("localization"))
Expand All @@ -42,11 +46,11 @@ def get_resource_path(filename: str) -> str:
root.iconphoto(True, PIL.ImageTk.PhotoImage(file=get_resource_path("images/Pillows_Hat_Icon.tga")))


def get_convertable_files(root_path: str) -> (list[str], int):
def get_convertable_files(root_path: str) -> (list[tuple[str, int]], int):
"""
Получение списка путей к конвертируемым файлов.
:param root_path: Путь к корневой директории.
:return: Список путей к файлам и общий размер файлов.
:return: Список из кортежа (путь к файлу, размер файла) и общий размер файлов.
"""
all_files = []
full_size = 0
Expand All @@ -56,18 +60,21 @@ def get_convertable_files(root_path: str) -> (list[str], int):
file_path = os.path.abspath(os.path.join(subdir, file))
if os.path.splitext(file_path)[1].lower() not in supported_extensions:
continue
all_files.append(file_path) # Полный путь к файлу.
full_size += os.stat(file_path).st_size
file_size = os.stat(file_path).st_size
all_files.append((file_path, file_size))
full_size += file_size
logging.info(i18n.t("main.indexing_stop"))
return all_files, full_size


def batch_convert_files(root_path: str, files: list[str], full_size: int) -> list[str]:
def batch_convert_files(root_path: str, file_tuples: list[tuple[str, int]], full_size: int,
use_concurrent: bool) -> list[str]:
"""
Конвертация изображений в нужный формат при соблюдении условий.
:param root_path: Корневой путь к директории (для вычисления относительного пути к файлу).
:param files: Список путей к изображениям.
:param file_tuples: Список кортежей из двух элементов - путь к файлу и размер файла в байтах.
:param full_size: Полный размер всех изображений в списке.
:param use_concurrent: Использовать асинхронные вызовы из concurrent.
:return: Список путей к изображениям, которые нужно удалить.
"""
resave_success = 0 # Количество изображений, которые успешно конвертированы.
Expand All @@ -78,54 +85,114 @@ def batch_convert_files(root_path: str, files: list[str], full_size: int) -> lis
with logging_redirect_tqdm():
pbar = tqdm(total=full_size, position=0, unit="B", unit_scale=True,
desc=i18n.t("main.files"))
for file_path in files:
file_path_rel = os.path.relpath(file_path, root_path)
pbar.set_postfix_str(file_path_rel)
try:
with PIL.Image.open(file_path) as img:
if use_concurrent:
with ThreadPoolExecutor() as executor:
future_convert_file = {executor.submit(convert_file, file_tuple[0]): file_tuple for
file_tuple in file_tuples}
for future in as_completed(future_convert_file):
file_tuple = future_convert_file[future]
file_path_rel = os.path.relpath(file_tuple[0], root_path)
pbar.set_postfix_str(file_path_rel)
try:
new_file_path = resave_img(img)
new_file_path = future.result()
except PIL.UnidentifiedImageError:
error_files.append(file_tuple[0])
logging.info(i18n.t("main.file_not_image"),
os.path.relpath(file_tuple[0], root_path))
except FileExistsError:
already_exist_files.append(file_tuple[0])
except OSError as e:
if e == FileExistsError:
already_exist_files.append(file_path)
logging.info(i18n.t("main.exception"), file_path_rel)
logging.info(e.strerror)
else:
if new_file_path != "":
pbar.clear()
logging.info("%s -> %s", file_path_rel,
new_file_path[len(root_path) + 1:])
if file_path not in obsolete_files:
obsolete_files.append(file_path)
if file_tuple[0] not in obsolete_files:
obsolete_files.append(file_tuple[0])
if new_file_path in obsolete_files:
obsolete_files.remove(new_file_path)
if new_file_path in error_files:
error_files.remove(new_file_path)
resave_success += 1
except OSError as e:
if e == PIL.UnidentifiedImageError:
pbar.clear()
logging.info(i18n.t("main.file_not_image"), file_path_rel)
else:
pbar.clear()
finally:
pbar.update(file_tuple[1])
else:
for file_tuple in file_tuples:
file_path_rel = os.path.relpath(file_tuple[0], root_path)
pbar.set_postfix_str(file_path_rel)
try:
new_file_path = convert_file(file_tuple[0])
except PIL.UnidentifiedImageError:
error_files.append(file_tuple[0])
logging.info(i18n.t("main.file_not_image"),
os.path.relpath(file_tuple[0], root_path))
except FileExistsError:
already_exist_files.append(file_tuple[0])
except OSError as e:
logging.info(i18n.t("main.exception"), file_path_rel)
logging.info(e)
error_files.append(file_path)
pbar.update(os.stat(file_path).st_size)
logging.info(e.strerror)
else:
if new_file_path != "":
logging.info("%s -> %s", file_path_rel, new_file_path[len(root_path) + 1:])
if file_tuple[0] not in obsolete_files:
obsolete_files.append(file_tuple[0])
if new_file_path in obsolete_files:
obsolete_files.remove(new_file_path)
if new_file_path in error_files:
error_files.remove(new_file_path)
resave_success += 1
finally:
pbar.update(file_tuple[1])
pbar.set_postfix_str("")
pbar.close()
log_statistics(root_path, error_files, resave_success, already_exist_files, obsolete_files)
return obsolete_files


def batch_remove_files(root_path: str, files: list[str]):
def convert_file(file_path: str) -> str:
"""
Попытка открытия и конвертирования одного изображения без обработки исключений.
:param file_path: Путь к файлу.
:return: Новый путь к файлу, если файл был удачно конвертирован или тот же, если файл был просто
пересохранён, а иначе - пустая строка.
"""
return resave_img(PIL.Image.open(file_path))


def batch_remove_files(root_path: str, file_paths: list[str], use_concurrent: bool):
"""
Удаление файлов.
:param root_path: Корневой путь к директории (для вычисления относительного пути к файлу).
:param files: Список путей к файлам.
:param file_paths: Список путей к файлам.
:param use_concurrent: Использовать асинхронные вызовы из concurrent.
"""
if use_concurrent:
with ThreadPoolExecutor() as executor:
future_remove_file = {executor.submit(remove_wrapper, file_path): file_path for
file_path in file_paths}
for future in as_completed(future_remove_file):
file_path = future_remove_file[future]
try:
future.result()
except OSError as e:
logging.info(i18n.t("main.exception_remove"), os.path.relpath(file_path, root_path))
logging.info(e)
else:
for file_path in file_paths:
try:
os.remove(file_path)
except OSError as e:
logging.info(i18n.t("main.exception_remove"), os.path.relpath(file_path, root_path))
logging.info(e)


def remove_wrapper(file_path: str):
"""
Обёртка для функции os.remove, чтобы Pylint не ругался.
:param file_path: Путь к файлу.
"""
for file in files:
try:
os.remove(file)
except OSError as e:
logging.info(i18n.t("main.exception_remove"), os.path.relpath(file, root_path))
logging.info(e)
os.remove(file_path)


def log_statistics(root_path: str, error_files: list[str], resave_success: int,
Expand Down Expand Up @@ -168,8 +235,8 @@ def main() -> str | int:
return 144 # ERROR_DIR_NOT_ROOT
return os.EX_OK
all_files, full_size = get_convertable_files(root_path)
obsolete_files = batch_convert_files(root_path, all_files, full_size)
batch_remove_files(root_path, obsolete_files)
obsolete_files = batch_convert_files(root_path, all_files, full_size, USE_CONCURRENT)
batch_remove_files(root_path, obsolete_files, USE_CONCURRENT)
return os.EX_OK


Expand Down

0 comments on commit 5fc7d85

Please sign in to comment.