fix signal interrupt handler + add doc
This commit is contained in:
		
							parent
							
								
									3a1c994b1e
								
							
						
					
					
						commit
						db484c5216
					
				| @ -1 +1,5 @@ | |||||||
|  | from .optimizer import ImgOptimizer, OptimizerResult | ||||||
|  | 
 | ||||||
|  | __all__ = ["ImgOptimizer", "OptimizerResult"] | ||||||
|  | 
 | ||||||
| VERSION = "0.1.0" | VERSION = "0.1.0" | ||||||
|  | |||||||
| @ -108,8 +108,19 @@ def main(): | |||||||
|         nb_workers, |         nb_workers, | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|     optimizer = ImgOptimizer(dest_dir, args.workers) |     optimizer = ImgOptimizer.init(dest_dir, args.workers) | ||||||
|  | 
 | ||||||
|  |     try: | ||||||
|         result = optimizer.optimize(fg) |         result = optimizer.optimize(fg) | ||||||
|  |     except KeyboardInterrupt: | ||||||
|  |         logging.info("optimizer stopped gracefully") | ||||||
|  |         exit(0) | ||||||
|  |     except Exception as e: | ||||||
|  |         logging.fatal( | ||||||
|  |             f"unexpected error occurred while optimizing, err: {e}", exc_info=True | ||||||
|  |         ) | ||||||
|  |         exit(1) | ||||||
|  | 
 | ||||||
|     (optimized, percent, size) = result.stats() |     (optimized, percent, size) = result.stats() | ||||||
| 
 | 
 | ||||||
|     logging.info( |     logging.info( | ||||||
|  | |||||||
							
								
								
									
										83
									
								
								src/files.py
									
									
									
									
									
								
							
							
						
						
									
										83
									
								
								src/files.py
									
									
									
									
									
								
							| @ -1,11 +1,11 @@ | |||||||
| import logging | import logging | ||||||
| import mimetypes | import mimetypes | ||||||
| import os | import os | ||||||
| import subprocess |  | ||||||
| from dataclasses import dataclass, field | from dataclasses import dataclass, field | ||||||
| from datetime import datetime as dt | from datetime import datetime as dt | ||||||
| from enum import Enum | from enum import Enum | ||||||
| from typing import Optional | 
 | ||||||
|  | __all__ = ["FileImgMimetype", "FileSizeRange", "File", "FileGroup", "Directory"] | ||||||
| 
 | 
 | ||||||
| DEFAULT_MIMETYPE = "unknown" | DEFAULT_MIMETYPE = "unknown" | ||||||
| 
 | 
 | ||||||
| @ -25,6 +25,15 @@ class FileImgMimetype(Enum): | |||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class FileSizeRange(Enum): | class FileSizeRange(Enum): | ||||||
|  |     """ | ||||||
|  |     Categorized files by their size in megabytes. | ||||||
|  | 
 | ||||||
|  |     * TINY: [0,1[ Mb | ||||||
|  |     * MEDIUM: [1,2[ Mb | ||||||
|  |     * LARGE: [2,5[ Mb | ||||||
|  |     * FAT: [5,inf[ Mb | ||||||
|  |     """ | ||||||
|  | 
 | ||||||
|     TINY = "TINY" |     TINY = "TINY" | ||||||
|     MEDIUM = "MEDIUM" |     MEDIUM = "MEDIUM" | ||||||
|     LARGE = "LARGE" |     LARGE = "LARGE" | ||||||
| @ -59,6 +68,15 @@ class FileSizeRange(Enum): | |||||||
| 
 | 
 | ||||||
| @dataclass(slots=True, frozen=True) | @dataclass(slots=True, frozen=True) | ||||||
| class File: | class File: | ||||||
|  |     """ | ||||||
|  |     Handle file main attributes. | ||||||
|  | 
 | ||||||
|  |     Example: | ||||||
|  |     ```python | ||||||
|  |     file = File.from_directory("dir-path", "my-file-name.png") | ||||||
|  |     ``` | ||||||
|  |     """ | ||||||
|  | 
 | ||||||
|     directory: str |     directory: str | ||||||
|     name: str |     name: str | ||||||
|     path: str |     path: str | ||||||
| @ -87,45 +105,14 @@ class File: | |||||||
|     def __repr__(self): |     def __repr__(self): | ||||||
|         return f"<FILE name={self.name} | dir={self.directory} | size={self.size:.2f} Mb | mimetype={self.mimetype}>"  # noqa |         return f"<FILE name={self.name} | dir={self.directory} | size={self.size:.2f} Mb | mimetype={self.mimetype}>"  # 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 for readability |  | ||||||
|         dest_dir = os.path.join(base_dest_dir, filepath.lstrip("/")).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 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.mimetype == FileImgMimetype.JPEG.value: |  | ||||||
|             return self._jpeg_opti(base_dest_dir) |  | ||||||
|         return None |  | ||||||
| 
 |  | ||||||
| 
 | 
 | ||||||
| @dataclass(slots=True) | @dataclass(slots=True) | ||||||
| class FileGroup: | class FileGroup: | ||||||
|  |     """ | ||||||
|  |     Group a bunch of `File`. That's all. | ||||||
|  |     Only useful to provide number of file and the whole size in Mb quickly. | ||||||
|  |     """ | ||||||
|  | 
 | ||||||
|     _files: dict[str, File] = field(default_factory=dict) |     _files: dict[str, File] = field(default_factory=dict) | ||||||
|     _size: float = 0 |     _size: float = 0 | ||||||
|     _nb_files: int = 0 |     _nb_files: int = 0 | ||||||
| @ -146,6 +133,7 @@ class FileGroup: | |||||||
|         return self._size |         return self._size | ||||||
| 
 | 
 | ||||||
|     def join(self, right: "FileGroup"): |     def join(self, right: "FileGroup"): | ||||||
|  |         """Include the whole `FileGroup` to its own.""" | ||||||
|         for filepath, file in right._files.items(): |         for filepath, file in right._files.items(): | ||||||
|             if self._files.get(filepath) is None: |             if self._files.get(filepath) is None: | ||||||
|                 self._files[filepath] = file |                 self._files[filepath] = file | ||||||
| @ -167,6 +155,22 @@ class FileGroup: | |||||||
| 
 | 
 | ||||||
| @dataclass(slots=True, frozen=True) | @dataclass(slots=True, frozen=True) | ||||||
| class Directory: | class Directory: | ||||||
|  |     """ | ||||||
|  |     Represents a directory path grouping files by mimetype and size range. | ||||||
|  | 
 | ||||||
|  |     Example: | ||||||
|  |     ```python | ||||||
|  |     directory = Directory.from_path("my-path") | ||||||
|  |     fg = directory.get_file_group() # collect all files | ||||||
|  | 
 | ||||||
|  |     # collect all tiny files of the directory | ||||||
|  |     fg_tiny = directory.get_file_group(size_range=FileSizeRange.TINY) | ||||||
|  | 
 | ||||||
|  |     # collect all JPEG files | ||||||
|  |     fg_jpeg = directory.get_file_group(mimetype=FileImgMimetype.JPEG) | ||||||
|  |     ``` | ||||||
|  |     """ | ||||||
|  | 
 | ||||||
|     path: str |     path: str | ||||||
|     nb_files: int |     nb_files: int | ||||||
|     details: dict[str, dict[FileSizeRange, FileGroup]] |     details: dict[str, dict[FileSizeRange, FileGroup]] | ||||||
| @ -175,6 +179,9 @@ class Directory: | |||||||
|         return self.nb_files |         return self.nb_files | ||||||
| 
 | 
 | ||||||
|     def show(self): |     def show(self): | ||||||
|  |         """ | ||||||
|  |         Display the whole directory files grouped by mimetype and size range. | ||||||
|  |         """ | ||||||
|         data = [f"directory ({self.path}) details:"] |         data = [f"directory ({self.path}) details:"] | ||||||
| 
 | 
 | ||||||
|         for mimetype, group in self.details.items(): |         for mimetype, group in self.details.items(): | ||||||
|  | |||||||
							
								
								
									
										113
									
								
								src/optimizer.py
									
									
									
									
									
								
							
							
						
						
									
										113
									
								
								src/optimizer.py
									
									
									
									
									
								
							| @ -1,5 +1,6 @@ | |||||||
| import logging | import logging | ||||||
| import os | import os | ||||||
|  | import signal | ||||||
| import subprocess | import subprocess | ||||||
| import time | import time | ||||||
| from concurrent.futures import ProcessPoolExecutor | from concurrent.futures import ProcessPoolExecutor | ||||||
| @ -8,46 +9,34 @@ from typing import Optional | |||||||
| 
 | 
 | ||||||
| from .files import File, FileGroup, FileImgMimetype | from .files import File, FileGroup, FileImgMimetype | ||||||
| 
 | 
 | ||||||
| 
 | __all__ = ["ImgOptimizer", "OptimizerResult"] | ||||||
| @dataclass(slots=True, frozen=True) |  | ||||||
| class OptimizerResult: |  | ||||||
|     orig: FileGroup |  | ||||||
|     opti: FileGroup |  | ||||||
|     optimized: int |  | ||||||
| 
 |  | ||||||
|     def stats(self) -> tuple[int, float, float]: |  | ||||||
|         percent = (1 - (self.opti._size / self.orig._size)) * 100 |  | ||||||
|         size = self.orig._size - self.opti._size |  | ||||||
|         return (self.optimized, percent, size) |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @dataclass(slots=True, frozen=True) | # TODO(rmanach): add argument to set the size or leave it empty for loseless optim | ||||||
| class ImgOptimizer: | def _jpeg_optim(dest_dir: str, file: File) -> tuple["File", Optional["File"]] | None: | ||||||
|     dest_dir: str |     """ | ||||||
|     nb_workers: int = 5 |     Optimize the `file` with `jpegoptim` and put the result in | ||||||
| 
 |     `dest_dir` directory keeping file path. | ||||||
|     def _jpeg_optim(self, file: File) -> tuple["File", Optional["File"]] | None: |     """ | ||||||
|     # remove ".." avoiding treat file in same dir |     # remove ".." avoiding treat file in same dir | ||||||
|     filepath = "/".join(file.path.split("/")[:-1]) |     filepath = "/".join(file.path.split("/")[:-1]) | ||||||
|     if filepath.startswith(".."): |     if filepath.startswith(".."): | ||||||
|         filepath = filepath.lstrip("..") |         filepath = filepath.lstrip("..") | ||||||
| 
 | 
 | ||||||
|     # replace all spaces in dir name for readability |     # replace all spaces in dir name for readability | ||||||
|         dest_dir = os.path.join(self.dest_dir, filepath.lstrip("/")).replace(" ", "_") |     dest_dir = os.path.join(dest_dir, filepath.lstrip("/")).replace(" ", "_") | ||||||
|     os.makedirs(dest_dir, exist_ok=True) |     os.makedirs(dest_dir, exist_ok=True) | ||||||
| 
 | 
 | ||||||
|     # use "-S <i>k" to set maximum size in kilobytes |     # use "-S <i>k" to set maximum size in kilobytes | ||||||
|     cmd = f"jpegoptim -s -p -q -S 1024k '{file.path}' -d {dest_dir}" |     cmd = f"jpegoptim -s -p -q -S 1024k '{file.path}' -d {dest_dir}" | ||||||
|         logging.debug("optimization launched for file: %s -> %s", self, cmd) |     logging.debug("optimization launched for file: %s -> %s", file, cmd) | ||||||
|     try: |     try: | ||||||
|         _ = subprocess.run(cmd, shell=True, check=True) |         _ = subprocess.run(cmd, shell=True, check=True) | ||||||
|     except subprocess.CalledProcessError as e: |     except subprocess.CalledProcessError as e: | ||||||
|         logging.error("error while running command: %s, err: %s", cmd, e.output) |         logging.error("error while running command: %s, err: %s", cmd, e.output) | ||||||
|         return None |         return None | ||||||
|     except Exception: |     except Exception: | ||||||
|             logging.error( |         logging.error("unexpected error while running command: %s", cmd, exc_info=True) | ||||||
|                 "unexpected error while running command: %s", cmd, exc_info=True |  | ||||||
|             ) |  | ||||||
|         return None |         return None | ||||||
| 
 | 
 | ||||||
|     try: |     try: | ||||||
| @ -58,16 +47,88 @@ class ImgOptimizer: | |||||||
| 
 | 
 | ||||||
|     return file, file_optim |     return file, file_optim | ||||||
| 
 | 
 | ||||||
|     def _optim(self, file: File) -> tuple["File", Optional["File"]] | None: | 
 | ||||||
|  | def _optim(dest_dir: str, file: File) -> tuple["File", Optional["File"]] | None: | ||||||
|  |     """ | ||||||
|  |     Entry point of `file` optimization selection the handler. | ||||||
|  |     NOTE: Must be launched in separated process. | ||||||
|  |     """ | ||||||
|  |     # ignore interrupt signal, catch by multiprocess executor | ||||||
|  |     signal.signal(signal.SIGINT, signal.SIG_IGN) | ||||||
|     if file.mimetype == FileImgMimetype.JPEG.value: |     if file.mimetype == FileImgMimetype.JPEG.value: | ||||||
|             return self._jpeg_optim(file) |         return _jpeg_optim(dest_dir, file) | ||||||
|     return None |     return None | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
|  | @dataclass(slots=True, frozen=True) | ||||||
|  | class OptimizerResult: | ||||||
|  |     """ | ||||||
|  |     Optimization result. | ||||||
|  |     Handle the original `FileGroup` and | ||||||
|  |     the optimized `FileGroup`. | ||||||
|  |     """ | ||||||
|  | 
 | ||||||
|  |     orig: FileGroup | ||||||
|  |     opti: FileGroup | ||||||
|  |     optimized: int | ||||||
|  | 
 | ||||||
|  |     def stats(self) -> tuple[int, float, float]: | ||||||
|  |         """ | ||||||
|  |         Returns the basics statistics of the optimization. | ||||||
|  | 
 | ||||||
|  |         Returns: | ||||||
|  |             tuple: (number of file optimized, percent of size gained, size gained in Mb) | ||||||
|  |         """ | ||||||
|  |         percent = (1 - (self.opti._size / self.orig._size)) * 100 | ||||||
|  |         size = self.orig._size - self.opti._size | ||||||
|  |         return (self.optimized, percent, size) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @dataclass(slots=True, frozen=True) | ||||||
|  | class ImgOptimizer: | ||||||
|  |     """ | ||||||
|  |     Wraps the optimization of JPEG and PNG files | ||||||
|  |     using `jpegoptim` and `optipng` on process pool. | ||||||
|  | 
 | ||||||
|  |     Example: | ||||||
|  |     ```python | ||||||
|  |         optimizer = ImgOptimizer("mypath") | ||||||
|  |         optimizer.optimize() | ||||||
|  |     ``` | ||||||
|  |     """ | ||||||
|  | 
 | ||||||
|  |     dest_dir: str | ||||||
|  |     _pool: ProcessPoolExecutor | ||||||
|  |     _orig_sigint_handler = signal.getsignal(signal.SIGINT) | ||||||
|  | 
 | ||||||
|  |     @classmethod | ||||||
|  |     def init(cls, dest_dir: str, nb_workers: int = 5) -> "ImgOptimizer": | ||||||
|  |         return ImgOptimizer(dest_dir, ProcessPoolExecutor(nb_workers)) | ||||||
|  | 
 | ||||||
|  |     def stop(self): | ||||||
|  |         logging.warning("stopping optimizer...") | ||||||
|  |         self._pool.shutdown(wait=True, cancel_futures=True) | ||||||
|  | 
 | ||||||
|  |     def _sigint_handler(self, signum, frame): | ||||||
|  |         logging.warning("interrupt signal received, stoppping optimizer...") | ||||||
|  |         signal.signal(signal.SIGINT, self._orig_sigint_handler) | ||||||
|  |         try: | ||||||
|  |             self.stop() | ||||||
|  |         except Exception as e: | ||||||
|  |             logging.debug( | ||||||
|  |                 "error occurred while stopping optimizer: %s", e, exc_info=True | ||||||
|  |             ) | ||||||
|  |             pass | ||||||
|  |         raise KeyboardInterrupt | ||||||
|  | 
 | ||||||
|     def optimize(self, file_group: FileGroup) -> OptimizerResult: |     def optimize(self, file_group: FileGroup) -> OptimizerResult: | ||||||
|  |         signal.signal(signal.SIGINT, self._sigint_handler) | ||||||
|         start = time.perf_counter() |         start = time.perf_counter() | ||||||
| 
 | 
 | ||||||
|         with ProcessPoolExecutor(self.nb_workers) as p: |         with self._pool as p: | ||||||
|             futures = [p.submit(self._optim, f) for f in file_group.get_files()] |             futures = [ | ||||||
|  |                 p.submit(_optim, self.dest_dir, f) for f in file_group.get_files() | ||||||
|  |             ] | ||||||
| 
 | 
 | ||||||
|         file_group_optim = FileGroup() |         file_group_optim = FileGroup() | ||||||
|         optimized = 0 |         optimized = 0 | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 rmanach
						rmanach