imgopt-cli 1.0.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.
imgopt/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
imgopt/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
imgopt/cli.py ADDED
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import sys
4
+ import time
5
+ import signal
6
+ import logging
7
+ from pathlib import Path
8
+ from concurrent.futures import ProcessPoolExecutor
9
+ from PIL import Image
10
+ from typing import Optional, Tuple, List, Union, Any, NamedTuple
11
+
12
+ # --- Project Metadata ---
13
+ __version__ = "1.0.0"
14
+ __prog_name__ = "imgopt"
15
+
16
+ # --- Configuration ---
17
+ EXTENSIONS = {'.png', '.jpg', '.jpeg', '.bmp', '.tiff'}
18
+
19
+ # --- Logging Setup ---
20
+ logging.basicConfig(
21
+ level=logging.INFO,
22
+ format='%(message)s',
23
+ stream=sys.stdout
24
+ )
25
+ logger = logging.getLogger()
26
+
27
+ class ImageTask(NamedTuple):
28
+ file_path: Path
29
+ output_root: Path
30
+ input_root: Path
31
+ quality: int
32
+ max_width: Optional[int]
33
+
34
+ def signal_handler(sig, frame) -> None:
35
+ """Handle termination signals (Ctrl+C) gracefully."""
36
+ logger.error("\n[!] Process interrupted. Exiting...")
37
+ sys.exit(1)
38
+
39
+ signal.signal(signal.SIGINT, signal_handler)
40
+ if hasattr(signal, 'SIGTERM'):
41
+ signal.signal(signal.SIGTERM, signal_handler)
42
+
43
+ def get_input_with_validation(prompt: str, validation_func: callable, default_value: Optional[str] = None) -> Any:
44
+ """
45
+ Prompts user for input and validates it in a loop.
46
+ Supports 'q' to quit.
47
+ """
48
+ while True:
49
+ display_prompt = f"{prompt}"
50
+ if default_value:
51
+ display_prompt += f" (Default: {default_value})"
52
+
53
+ user_input = input(f"{display_prompt}: ").strip()
54
+
55
+ if user_input.lower() in ['q', 'quit', 'exit']:
56
+ sys.exit(0)
57
+
58
+ if not user_input and default_value:
59
+ return default_value
60
+
61
+ result = validation_func(user_input)
62
+ if result is not None:
63
+ return result
64
+
65
+ logger.warning("Invalid input. Try again.")
66
+
67
+ def validate_path(path_str: str) -> Optional[Path]:
68
+ """Checks if the path exists."""
69
+ if not path_str:
70
+ return None
71
+ path = Path(path_str).resolve()
72
+ if path.exists():
73
+ return path
74
+ logger.warning(f"Path not found: {path_str}")
75
+ return None
76
+
77
+ def validate_width(width_str: str) -> Union[int, str, None]:
78
+ """Parses width input. Returns int, 'SKIP', or None."""
79
+ if not width_str:
80
+ return None
81
+ if width_str.lower() in ['0', 'n', 'no', 'skip']:
82
+ return 'SKIP'
83
+ try:
84
+ val = int(width_str)
85
+ if val >= 0:
86
+ return val if val > 0 else 'SKIP'
87
+ return None
88
+ except ValueError:
89
+ return None
90
+
91
+ def validate_yes_no(val_str: str) -> Optional[bool]:
92
+ """Parses yes/no string to boolean."""
93
+ if not val_str:
94
+ return None
95
+ if val_str.lower().startswith('y'):
96
+ return True
97
+ if val_str.lower().startswith('n'):
98
+ return False
99
+ return None
100
+
101
+ def get_unique_output_folder(base_folder: Path, name: str) -> Path:
102
+ """Ensures output folder name is unique to avoid collisions."""
103
+ output_path = base_folder / name
104
+ if output_path.exists() and output_path.is_file():
105
+ counter = 1
106
+ while True:
107
+ new_name = f"{name}_{counter}"
108
+ new_path = base_folder / new_name
109
+ if not new_path.is_file():
110
+ return new_path
111
+ counter += 1
112
+ return output_path
113
+
114
+ def process_single_image(task: ImageTask) -> Tuple[bool, str, int, int]:
115
+ """
116
+ Core image processing logic.
117
+ Handles resizing and conversion to WebP.
118
+ """
119
+ try:
120
+ relative_path = task.file_path.relative_to(task.input_root)
121
+ output_file_path = task.output_root / relative_path.with_suffix('.webp')
122
+ output_file_path.parent.mkdir(parents=True, exist_ok=True)
123
+
124
+ original_size = task.file_path.stat().st_size
125
+
126
+ with Image.open(task.file_path) as img:
127
+ # Smart Resize
128
+ if task.max_width and img.width > task.max_width:
129
+ ratio = task.max_width / img.width
130
+ new_height = int(img.height * ratio)
131
+ img = img.resize((task.max_width, new_height), Image.Resampling.LANCZOS)
132
+
133
+ img.save(output_file_path, 'webp', quality=task.quality, method=6)
134
+
135
+ new_size = output_file_path.stat().st_size
136
+ return (True, task.file_path.name, original_size, new_size)
137
+
138
+ except Exception as e:
139
+ return (False, f"{task.file_path.name}: {e}", 0, 0)
140
+
141
+ def main():
142
+ """
143
+ Main entry point for the CLI.
144
+ Parses arguments, handles interactive mode, and orchestrates the batch processing.
145
+ """
146
+ # Setting prog='imgopt' hides 'optimize.py' and makes it look like a binary
147
+ parser = argparse.ArgumentParser(
148
+ prog=__prog_name__,
149
+ description="A high-performance CLI tool to batch optimize images for the web.",
150
+ formatter_class=argparse.RawTextHelpFormatter,
151
+ epilog="Examples:\n"
152
+ " imgopt (Interactive Wizard)\n"
153
+ " imgopt ./photos -q 90 (Quick mode, high quality)\n"
154
+ " imgopt ./photos -w 0 (Convert only, no resize)\n"
155
+ " imgopt ./photos --output dist (Custom output folder)"
156
+ )
157
+
158
+ # --- Professional CLI Arguments ---
159
+ parser.add_argument("-v", "--version", action="version", version=f"{__prog_name__} {__version__}")
160
+
161
+ parser.add_argument("path", nargs="?", help="Input directory path containing images.")
162
+
163
+ parser.add_argument("-o", "--output", type=str, default="optimized_webp",
164
+ help="Name of the output folder (default: optimized_webp).")
165
+
166
+ parser.add_argument("-w", "--width", type=str, default="1920",
167
+ help="Max width in pixels. Use '0' to keep original size (default: 1920).")
168
+
169
+ parser.add_argument("-q", "--quality", type=int, default=80,
170
+ help="WebP quality (0-100) (default: 80).")
171
+
172
+ parser.add_argument("-i", "--interactive", action="store_true",
173
+ help="Force launch of the interactive wizard.")
174
+
175
+ parser.add_argument("--quiet", action="store_true",
176
+ help="Suppress per-file logs, showing only the final summary.")
177
+
178
+ parser.add_argument("--no-sound", action="store_true",
179
+ help="Disable the completion notification sound (Beep).")
180
+
181
+ args = parser.parse_args()
182
+
183
+ # Defaults
184
+ input_dir = None
185
+ target_width = None
186
+ output_folder_name = args.output
187
+ quality = args.quality
188
+ verbose = not args.quiet
189
+ play_sound = not args.no_sound
190
+ is_interactive = args.interactive
191
+
192
+ # Auto-trigger interactive if no path is given
193
+ if len(sys.argv) == 1:
194
+ is_interactive = True
195
+
196
+ # --- Interactive Wizard Logic ---
197
+ if is_interactive:
198
+ logger.info(f"{__prog_name__} v{__version__}")
199
+ logger.info("Interactive Mode (Press 'q' to quit at any time).\n")
200
+
201
+ input_dir = get_input_with_validation("Input folder path", validate_path)
202
+
203
+ width_result = get_input_with_validation(
204
+ "Max width (0 for original)",
205
+ validate_width,
206
+ default_value="1920"
207
+ )
208
+ target_width = None if width_result == 'SKIP' else width_result
209
+
210
+ output_folder_name = get_input_with_validation(
211
+ "Output folder name",
212
+ lambda x: x if x else None,
213
+ default_value="optimized_webp"
214
+ )
215
+
216
+ verbose = get_input_with_validation(
217
+ "Show details? (y/n)",
218
+ validate_yes_no,
219
+ default_value="n"
220
+ )
221
+
222
+ # Audio preference in interactive mode
223
+ play_sound = get_input_with_validation(
224
+ "Play sound when done? (y/n)",
225
+ validate_yes_no,
226
+ default_value="y"
227
+ )
228
+
229
+ else:
230
+ # --- CLI Mode Logic ---
231
+ input_dir = validate_path(args.path)
232
+ if not input_dir:
233
+ # If path is invalid in CLI mode, show help
234
+ parser.print_help()
235
+ sys.exit(1)
236
+
237
+ # Parse width argument from CLI
238
+ w_val = validate_width(args.width)
239
+ target_width = None if w_val == 'SKIP' else w_val
240
+
241
+ # --- Execution Logic ---
242
+ output_dir = get_unique_output_folder(input_dir, output_folder_name)
243
+ if input_dir == output_dir:
244
+ logger.error("Error: Input and Output folders cannot be the same.")
245
+ sys.exit(1)
246
+
247
+ output_dir.mkdir(exist_ok=True)
248
+
249
+ logger.info("Scanning...")
250
+ files = [
251
+ f for f in input_dir.rglob("*")
252
+ if f.suffix.lower() in EXTENSIONS
253
+ and f.is_file()
254
+ and output_dir not in f.parents
255
+ ]
256
+
257
+ if not files:
258
+ logger.warning("No images found.")
259
+ sys.exit(0)
260
+
261
+ # Info Summary
262
+ logger.info("-" * 40)
263
+ logger.info(f"Source: {input_dir}")
264
+ logger.info(f"Target: {output_dir.name}")
265
+ logger.info(f"Files: {len(files)}")
266
+ logger.info(f"Width: {target_width if target_width else 'Original'}")
267
+ logger.info(f"Quality: {quality}")
268
+ logger.info("-" * 40)
269
+
270
+ start_time = time.time()
271
+
272
+ # Create Task Objects
273
+ tasks = [
274
+ ImageTask(f, output_dir, input_dir, quality, target_width)
275
+ for f in files
276
+ ]
277
+
278
+ success = 0
279
+ failed = 0
280
+ orig_total = 0
281
+ new_total = 0
282
+
283
+ try:
284
+ with ProcessPoolExecutor() as executor:
285
+ results = executor.map(process_single_image, tasks)
286
+ for is_ok, msg, orig, new_s in results:
287
+ if is_ok:
288
+ success += 1
289
+ orig_total += orig
290
+ new_total += new_s
291
+ if verbose: logger.info(f"OK: {msg}")
292
+ else:
293
+ failed += 1
294
+ logger.error(f"FAIL: {msg}")
295
+
296
+ except KeyboardInterrupt:
297
+ logger.error("\nCancelled.")
298
+ sys.exit(1)
299
+
300
+ saved = orig_total - new_total
301
+ saved_mb = saved / (1024 * 1024)
302
+ pct = (saved / orig_total * 100) if orig_total > 0 else 0
303
+
304
+ logger.info("\n" + "=" * 40)
305
+ logger.info(f"Finished: {success} OK | {failed} Failed")
306
+ logger.info(f"Saved: {saved_mb:.2f} MB ({pct:.1f}%)")
307
+ logger.info("=" * 40)
308
+
309
+ if play_sound: print('\a')
310
+ if success == 0 and len(files) > 0: sys.exit(1)
311
+ sys.exit(0)
312
+
313
+ if __name__ == "__main__":
314
+ main()
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: imgopt-cli
3
+ Version: 1.0.0
4
+ Summary: A high-performance CLI tool to batch optimize images for the web.
5
+ Author: Ahmed Samy El-khouly
6
+ License: MIT License
7
+
8
+ Copyright (c) 2025 Ahmed Samy El-khouly
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/ahmedthebest31/imgopt
29
+ Project-URL: Bug Tracker, https://github.com/ahmedthebest31/imgopt/issues
30
+ Classifier: Programming Language :: Python :: 3
31
+ Classifier: License :: OSI Approved :: MIT License
32
+ Classifier: Operating System :: OS Independent
33
+ Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
34
+ Requires-Python: >=3.8
35
+ Description-Content-Type: text/markdown
36
+ License-File: LICENSE
37
+ Requires-Dist: Pillow>=10.3.0
38
+ Dynamic: license-file
39
+
40
+ # imgopt 🚀
41
+
42
+ **The Intelligent WebP Converter for Modern Web Development.**
43
+
44
+ `imgopt` is a high-performance, accessibility-first CLI tool designed to batch convert and optimize images (PNG, JPG, TIFF) into efficient WebP format. It features smart resizing, concurrency for speed, and an interactive wizard mode.
45
+
46
+ ## Features ✨
47
+
48
+ - **Batch Processing:** Convert hundreds of images in seconds using multi-core processing.
49
+ - **Smart Resizing:** Automatically downscale images to a target width (e.g., 1920px) while maintaining aspect ratio.
50
+ - **Interactive Wizard:** No need to memorize flags; just run `imgopt` and follow the prompts.
51
+ - **Accessibility Friendly:** Screen-reader friendly outputs and audio cues upon completion.
52
+ - **Safe:** Never overwrites your original files.
53
+
54
+ ## Installation 📦
55
+
56
+ You can install `imgopt` directly from PyPI (coming soon) or from source:
57
+
58
+ ```bash
59
+ pip install .
60
+ ```
61
+
62
+ ## Usage 🛠️
63
+
64
+ ### Interactive Mode (Wizard)
65
+ Just run the command without arguments:
66
+ ```bash
67
+ imgopt
68
+ ```
69
+
70
+ ### Quick CLI Mode
71
+ Optimize all images in a folder to 80% quality WebP, max width 1920px:
72
+ ```bash
73
+ imgopt ./photos
74
+ ```
75
+
76
+ ### Advanced Usage
77
+ ```bash
78
+ # Custom output folder, no resizing, 90% quality
79
+ imgopt ./raw_images --output ./web_ready --width 0 --quality 90
80
+
81
+ # Quiet mode (no per-file logs)
82
+ imgopt ./assets --quiet
83
+ ```
84
+
85
+ ## Requirements
86
+ - Python 3.8+
87
+ - Pillow
88
+
89
+ ## License
90
+ MIT License. See [LICENSE](LICENSE) for details.
@@ -0,0 +1,9 @@
1
+ imgopt/__init__.py,sha256=ZhzQKWZ8RFrTIkj7z87B144DI95e8LMfA5w8NDWQDtg,23
2
+ imgopt/__main__.py,sha256=EClCwCzb6h6YBpt0hrnG4h0mlNhNePyg_xBNNSVm1os,65
3
+ imgopt/cli.py,sha256=24FtX6HQFCP41ru4V1fA0-jDh6s6s4Av2HHIWXVq-NY,10515
4
+ imgopt_cli-1.0.0.dist-info/licenses/LICENSE,sha256=5SKr7MM_5tOOQ9ssCZot370lzfeR2KzmuVFSx5u-FjE,1097
5
+ imgopt_cli-1.0.0.dist-info/METADATA,sha256=M7yVuZaiZ8zkN26C0PRPA8NBhG405YcaX2QMV_9hp0E,3439
6
+ imgopt_cli-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
7
+ imgopt_cli-1.0.0.dist-info/entry_points.txt,sha256=aUIZvtmLeoKCZMQGMc3_skQuHK03qWrA-QaYX6ppOUI,43
8
+ imgopt_cli-1.0.0.dist-info/top_level.txt,sha256=t3Sc_lwb3YfyD2-5WTDK3_fmSKYdYUTx6mP1TdkkCLY,7
9
+ imgopt_cli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ imgopt = imgopt.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Ahmed Samy El-khouly
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ imgopt