init repository
This commit is contained in:
		
						commit
						c2084a5166
					
				
							
								
								
									
										7
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,7 @@ | |||||||
|  | .mypy_cache | ||||||
|  | .ruff_cache | ||||||
|  | 
 | ||||||
|  | venv | ||||||
|  | data | ||||||
|  | 
 | ||||||
|  | *.log | ||||||
							
								
								
									
										18
									
								
								Makefile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								Makefile
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,18 @@ | |||||||
|  | ROOT_DIR := $(dir $(realpath $(lastword $(MAKEFILE_LIST)))) | ||||||
|  | PYTHON := $(ROOT_DIR)venv/bin/python | ||||||
|  | 
 | ||||||
|  | .PHONY: venv | ||||||
|  | venv: | ||||||
|  | 	@python3 -m venv venv | ||||||
|  | 	pip install -r requirements-dev.txt | ||||||
|  | 
 | ||||||
|  | lint: | ||||||
|  | 	$(PYTHON) -m ruff check --fix | ||||||
|  | 
 | ||||||
|  | format: | ||||||
|  | 	$(PYTHON) -m ruff format | ||||||
|  | 
 | ||||||
|  | check-type: | ||||||
|  | 	$(PYTHON) -m mypy . | ||||||
|  | 
 | ||||||
|  | check: format lint check-type | ||||||
							
								
								
									
										287
									
								
								imgopti.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										287
									
								
								imgopti.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,287 @@ | |||||||
|  | import logging | ||||||
|  | import mimetypes | ||||||
|  | import os | ||||||
|  | import subprocess | ||||||
|  | import sys | ||||||
|  | import time | ||||||
|  | from concurrent.futures import ProcessPoolExecutor | ||||||
|  | from dataclasses import dataclass, field | ||||||
|  | from datetime import datetime as dt | ||||||
|  | from enum import Enum, auto | ||||||
|  | from typing import Optional | ||||||
|  | 
 | ||||||
|  | SRC_PATH = "../users/lea/pictures" | ||||||
|  | DEFAULT_MIMETYPE = "unknown" | ||||||
|  | DEFAULT_DEST_DIR = "data" | ||||||
|  | DEFAULT_NB_WORKERS = 10 | ||||||
|  | 
 | ||||||
|  | JPEG_MIMETYPE = "image/jpeg" | ||||||
|  | PNG_MIMETYPE = "image/png" | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class FileSizeRange(Enum): | ||||||
|  |     TINY = auto() | ||||||
|  |     MEDIUM = auto() | ||||||
|  |     LARGE = auto() | ||||||
|  |     FAT = auto() | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def from_size(cls, size: float) -> "FileSizeRange": | ||||||
|  |         if size < 1: | ||||||
|  |             return cls.TINY | ||||||
|  | 
 | ||||||
|  |         if size >= 1 and size < 2: | ||||||
|  |             return cls.MEDIUM | ||||||
|  | 
 | ||||||
|  |         if size >= 2 and size < 5: | ||||||
|  |             return cls.LARGE | ||||||
|  | 
 | ||||||
|  |         return cls.FAT | ||||||
|  | 
 | ||||||
|  |     def __str__(self): | ||||||
|  |         match self: | ||||||
|  |             case FileSizeRange.TINY: | ||||||
|  |                 return "tiny" | ||||||
|  |             case FileSizeRange.MEDIUM: | ||||||
|  |                 return "medium" | ||||||
|  |             case FileSizeRange.LARGE: | ||||||
|  |                 return "large" | ||||||
|  |             case FileSizeRange.FAT: | ||||||
|  |                 return "fat" | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @dataclass(slots=True, frozen=True) | ||||||
|  | class File: | ||||||
|  |     directory: str | ||||||
|  |     name: str | ||||||
|  |     path: str | ||||||
|  |     mime_type: str | ||||||
|  |     size: float | ||||||
|  |     size_range: FileSizeRange | ||||||
|  |     modified: dt | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def from_directory(cls, directory: str, name: str) -> "File": | ||||||
|  |         path = os.path.join(directory, name) | ||||||
|  | 
 | ||||||
|  |         mtype, _ = mimetypes.guess_type(path) | ||||||
|  |         mime_type = mtype or DEFAULT_MIMETYPE | ||||||
|  | 
 | ||||||
|  |         size = os.path.getsize(path) / 1_048_576 | ||||||
|  | 
 | ||||||
|  |         return File( | ||||||
|  |             directory, | ||||||
|  |             name, | ||||||
|  |             path, | ||||||
|  |             mime_type, | ||||||
|  |             size, | ||||||
|  |             FileSizeRange.from_size(size), | ||||||
|  |             dt.fromtimestamp(os.path.getmtime(path)), | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     def __repr__(self): | ||||||
|  |         return f"<FILE name={self.name} | dir={self.directory} | size={self.size:.2f} Mb | mtype={self.mime_type}>"  # noqa | ||||||
|  | 
 | ||||||
|  |     def _jpeg_opti(self, base_dest_dir: str) -> tuple["File", Optional["File"]] | None: | ||||||
|  |         # remove ".." avoiding treat file in same dir | ||||||
|  |         filepath = "/".join(self.path.split("/")[:-1]) | ||||||
|  |         if filepath.startswith(".."): | ||||||
|  |             filepath = filepath.lstrip("../") | ||||||
|  | 
 | ||||||
|  |         # replace all spaces in dir name | ||||||
|  |         dest_dir = os.path.join(base_dest_dir, filepath).replace(" ", "_") | ||||||
|  |         os.makedirs(dest_dir, exist_ok=True) | ||||||
|  | 
 | ||||||
|  |         cmd = f"jpegoptim -s -p -q '{self.path}' -d {dest_dir}" | ||||||
|  |         logging.debug("optimization launched for file: %s -> %s", self, cmd) | ||||||
|  |         try: | ||||||
|  |             _ = subprocess.run(cmd, shell=True, check=True) | ||||||
|  |         except subprocess.CalledProcessError as e: | ||||||
|  |             logging.error("error while running command: %s, err: %s", cmd, e.output) | ||||||
|  |             return self, None | ||||||
|  |         except Exception: | ||||||
|  |             logging.error( | ||||||
|  |                 "unexpected error while running command: %s", cmd, exc_info=True | ||||||
|  |             ) | ||||||
|  |             return None | ||||||
|  | 
 | ||||||
|  |         try: | ||||||
|  |             f_opti = File.from_directory(dest_dir, self.name) | ||||||
|  |         except Exception as e: | ||||||
|  |             logging.debug("unable to get file: %s after optimization: %s", self, e) | ||||||
|  |             return self, None | ||||||
|  | 
 | ||||||
|  |         return self, f_opti | ||||||
|  | 
 | ||||||
|  |     def opti(self, base_dest_dir: str) -> tuple["File", Optional["File"]] | None: | ||||||
|  |         if self.mime_type == JPEG_MIMETYPE: | ||||||
|  |             return self._jpeg_opti(base_dest_dir) | ||||||
|  |         return None | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @dataclass(slots=True) | ||||||
|  | class FileGroup: | ||||||
|  |     mime_type: str | ||||||
|  |     file_range: FileSizeRange | ||||||
|  |     files: dict[str, File] = field(default_factory=dict) | ||||||
|  |     size: float = 0 | ||||||
|  |     _nb_files: int = 0 | ||||||
|  | 
 | ||||||
|  |     def __repr__(self): | ||||||
|  |         return f"<FILEGROUP mime_type={self.mime_type} | range={self.file_range} | n={self._nb_files} | size={self.size:.2f} Mb>"  # noqa | ||||||
|  | 
 | ||||||
|  |     def __len__(self): | ||||||
|  |         return self._nb_files | ||||||
|  | 
 | ||||||
|  |     def add(self, file: File): | ||||||
|  |         if self.files.get(file.path) is None: | ||||||
|  |             self.files[file.path] = file | ||||||
|  |             self._nb_files += 1 | ||||||
|  |             self.size += file.size | ||||||
|  | 
 | ||||||
|  |     def get_size(self) -> float: | ||||||
|  |         return self.size | ||||||
|  | 
 | ||||||
|  |     @staticmethod | ||||||
|  |     def format_size(size: float) -> str: | ||||||
|  |         if size < 1000: | ||||||
|  |             return f"{size:.2f} Mb" | ||||||
|  |         return f"{size / 1024:.2f} Gb" | ||||||
|  | 
 | ||||||
|  |     def get_size_formatted(self) -> str: | ||||||
|  |         return FileGroup.format_size(self.size) | ||||||
|  | 
 | ||||||
|  |     def get_files(self) -> list[File]: | ||||||
|  |         return list(self.files.values()) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @dataclass(slots=True, frozen=True) | ||||||
|  | class Dir: | ||||||
|  |     path: str | ||||||
|  |     nb_files: int | ||||||
|  |     details: dict[str, dict[FileSizeRange, FileGroup]] | ||||||
|  | 
 | ||||||
|  |     def show(self): | ||||||
|  |         data = [f"directory ({self.path}) details:"] | ||||||
|  | 
 | ||||||
|  |         for mime_type, group in self.details.items(): | ||||||
|  |             nb_files = 0 | ||||||
|  |             size = 0 | ||||||
|  |             to_display = [f"* {mime_type}"] | ||||||
|  | 
 | ||||||
|  |             for file_range in group.keys(): | ||||||
|  |                 file_group = self.details[mime_type][file_range] | ||||||
|  |                 to_display.append( | ||||||
|  |                     f"\t{file_range:<8}{len(file_group):<8}{file_group.get_size_formatted()}" | ||||||
|  |                 ) | ||||||
|  |                 nb_files += len(self.details[mime_type][file_range]) | ||||||
|  |                 size += file_group.size | ||||||
|  | 
 | ||||||
|  |             to_display[0] += f" ({FileGroup.format_size(size)})" | ||||||
|  | 
 | ||||||
|  |             data.append("\n".join(to_display)) | ||||||
|  | 
 | ||||||
|  |         print("\n".join(data)) | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def from_path(cls, path: str) -> "Dir": | ||||||
|  |         if not os.path.isdir(path): | ||||||
|  |             raise Exception(f"Dir path: {path} must be a directory") | ||||||
|  | 
 | ||||||
|  |         nb_files = 0 | ||||||
|  |         details: dict[str, dict[FileSizeRange, FileGroup]] = {} | ||||||
|  |         for dirpath, _, filenames in os.walk(path): | ||||||
|  |             for file in filenames: | ||||||
|  |                 file_path = os.path.join(dirpath, file) | ||||||
|  |                 try: | ||||||
|  |                     f = File.from_directory(dirpath, file) | ||||||
|  |                 except OSError as e: | ||||||
|  |                     logging.error("error accessing %s, err: %s", file_path, e) | ||||||
|  |                     continue | ||||||
|  | 
 | ||||||
|  |                 if details.get(f.mime_type) is None: | ||||||
|  |                     details[f.mime_type] = {} | ||||||
|  | 
 | ||||||
|  |                 if details[f.mime_type].get(f.size_range) is None: | ||||||
|  |                     details[f.mime_type][f.size_range] = FileGroup( | ||||||
|  |                         f.mime_type, f.size_range | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  |                 details[f.mime_type][f.size_range].add(f) | ||||||
|  |                 nb_files += 1 | ||||||
|  | 
 | ||||||
|  |         return Dir(path, nb_files, details) | ||||||
|  | 
 | ||||||
|  |     def get_file_group( | ||||||
|  |         self, mimetype: str, file_size: FileSizeRange | ||||||
|  |     ) -> FileGroup | None: | ||||||
|  |         if (mt := self.details.get(mimetype)) is not None: | ||||||
|  |             return mt.get(file_size) | ||||||
|  |         return None | ||||||
|  | 
 | ||||||
|  |     def get_files(self) -> list[File]: | ||||||
|  |         files = [] | ||||||
|  |         for details in self.details.values(): | ||||||
|  |             for file_group in details.values(): | ||||||
|  |                 files.extend(file_group.get_files()) | ||||||
|  |         return files | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | if __name__ == "__main__": | ||||||
|  |     stdout_handler = logging.StreamHandler(stream=sys.stdout) | ||||||
|  |     logging.basicConfig( | ||||||
|  |         format="[%(levelname)s] - %(asctime)s - %(message)s", | ||||||
|  |         level=logging.INFO, | ||||||
|  |         handlers=(stdout_handler,), | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     d = Dir.from_path(SRC_PATH) | ||||||
|  |     d.show() | ||||||
|  | 
 | ||||||
|  |     os.makedirs(DEFAULT_DEST_DIR, exist_ok=True) | ||||||
|  | 
 | ||||||
|  |     mtype = JPEG_MIMETYPE | ||||||
|  |     frange = FileSizeRange.FAT | ||||||
|  |     nb_workers = DEFAULT_NB_WORKERS | ||||||
|  | 
 | ||||||
|  |     fg = d.get_file_group(mtype, frange) | ||||||
|  |     if fg is None: | ||||||
|  |         logging.error( | ||||||
|  |             "no files found for mimetype: %s and file size range: %s", mtype, frange | ||||||
|  |         ) | ||||||
|  |         exit(1) | ||||||
|  | 
 | ||||||
|  |     logging.info( | ||||||
|  |         "launching optimization (%d) for %s and range %s on %d workers...", | ||||||
|  |         len(fg), | ||||||
|  |         mtype, | ||||||
|  |         frange, | ||||||
|  |         nb_workers, | ||||||
|  |     ) | ||||||
|  |     start = time.perf_counter() | ||||||
|  | 
 | ||||||
|  |     with ProcessPoolExecutor(nb_workers) as p: | ||||||
|  |         futures = [p.submit(f.opti, DEFAULT_DEST_DIR) for f in fg.get_files()] | ||||||
|  | 
 | ||||||
|  |     fg_opti = FileGroup(mtype, frange) | ||||||
|  |     optimized = 0 | ||||||
|  |     for f in futures: | ||||||
|  |         if (res := f.result()) and res is not None: | ||||||
|  |             match res: | ||||||
|  |                 case (orig, None): | ||||||
|  |                     logging.debug(f"no optimization for file: {orig}") | ||||||
|  |                     fg_opti.add(orig) | ||||||
|  |                 case (orig, opti): | ||||||
|  |                     optimized += 1 | ||||||
|  |                     logging.debug( | ||||||
|  |                         f"optimization for file: {orig} -> {(1 - (opti.size / orig.size)) * 100:.2f}%"  # noqa | ||||||
|  |                     ) | ||||||
|  |                     fg_opti.add(opti) | ||||||
|  | 
 | ||||||
|  |     logging.info(f"optimization finished in {time.perf_counter() - start:.2f}s") | ||||||
|  | 
 | ||||||
|  |     percent = (1 - (fg_opti.size / fg.size)) * 100 | ||||||
|  |     size_gained = fg.size - fg_opti.size | ||||||
|  |     logging.info( | ||||||
|  |         f"total optimization ({optimized}/{len(fg)}): {percent:.2f}% -> {size_gained:.2f} Mb"  # noqa | ||||||
|  |     ) | ||||||
							
								
								
									
										26
									
								
								pyproject.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								pyproject.toml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,26 @@ | |||||||
|  | [project] | ||||||
|  | name = "imgopti" | ||||||
|  | dynamic = ["version"] | ||||||
|  | authors = [] | ||||||
|  | requires-python = ">= 3.10" | ||||||
|  | 
 | ||||||
|  | [tool.ruff.lint] | ||||||
|  | select = ["E", "F", "I"] | ||||||
|  | ignore = [] | ||||||
|  | 
 | ||||||
|  | [tool.ruff] | ||||||
|  | exclude = [ | ||||||
|  |     "venv", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | line-length = 88 | ||||||
|  | target-version = "py311" | ||||||
|  | 
 | ||||||
|  | [tool.ruff.lint.mccabe] | ||||||
|  | max-complexity = 10 | ||||||
|  | 
 | ||||||
|  | [tool.mypy] | ||||||
|  | exclude = [ | ||||||
|  |     "venv", | ||||||
|  | ] | ||||||
|  | ignore_missing_imports = true | ||||||
							
								
								
									
										2
									
								
								requirements-dev.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								requirements-dev.txt
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,2 @@ | |||||||
|  | mypy==1.18.2 | ||||||
|  | ruff==0.14.1 | ||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 rmanach
						rmanach