py-bulk-image-compressor 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.
- components/__init__.py +0 -0
- components/imageCompressor.py +73 -0
- components/localColors.py +13 -0
- components/mainClasses.py +14 -0
- components/mainFunctions.py +136 -0
- components/setConfigurations.py +9 -0
- data/__init__.py +0 -0
- py_bulk_image_compressor-1.0.0.dist-info/LICENSE +21 -0
- py_bulk_image_compressor-1.0.0.dist-info/METADATA +184 -0
- py_bulk_image_compressor-1.0.0.dist-info/RECORD +17 -0
- py_bulk_image_compressor-1.0.0.dist-info/WHEEL +5 -0
- py_bulk_image_compressor-1.0.0.dist-info/top_level.txt +3 -0
- tests/__init__.py +0 -0
- tests/test_imageCompressor.py +34 -0
- tests/test_main.py +26 -0
- tests/test_mainFunctions.py +35 -0
- tests/test_setConfigurations.py +13 -0
components/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
import sys
|
|
3
|
+
from PIL import Image, ImageFile
|
|
4
|
+
from pillow_heif import register_heif_opener
|
|
5
|
+
|
|
6
|
+
# Mitigate issues with truncated images
|
|
7
|
+
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
|
8
|
+
|
|
9
|
+
# Add HEIC/HEIF
|
|
10
|
+
register_heif_opener()
|
|
11
|
+
|
|
12
|
+
avif_supported = True
|
|
13
|
+
try:
|
|
14
|
+
import avif # noqa
|
|
15
|
+
except ImportError:
|
|
16
|
+
avif_supported = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def compress_resize_image(**kwargs) -> None:
|
|
20
|
+
"""
|
|
21
|
+
Compress/resize. Remove metadata if remove_meta=True.
|
|
22
|
+
If format == avif but 'pillow-avif-plugin' is not installed, skip & raise.
|
|
23
|
+
"""
|
|
24
|
+
remove_meta = kwargs.get('remove_meta', False)
|
|
25
|
+
set_format = kwargs['set_format'].lower()
|
|
26
|
+
|
|
27
|
+
# If user wants AVIF but plugin is not installed, raise an exception
|
|
28
|
+
if set_format == 'avif' and not avif_supported:
|
|
29
|
+
raise ValueError("AVIF plugin (pillow-avif-plugin) not installed. Cannot save as AVIF.")
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
with Image.open(kwargs['image_location']) as im:
|
|
33
|
+
# If removing meta, skip exif
|
|
34
|
+
if not remove_meta:
|
|
35
|
+
exif_data = im.info.get('exif')
|
|
36
|
+
else:
|
|
37
|
+
exif_data = None
|
|
38
|
+
|
|
39
|
+
# Convert mode
|
|
40
|
+
if im.mode in ("RGBA", "LA") or (im.mode == "P" and "transparency" in im.info):
|
|
41
|
+
im = im.convert("RGBA")
|
|
42
|
+
else:
|
|
43
|
+
im = im.convert("RGB")
|
|
44
|
+
|
|
45
|
+
# Calculate new dimensions
|
|
46
|
+
if kwargs['max_width'] is None or im.width <= kwargs['max_width']:
|
|
47
|
+
w, h = im.width, im.height
|
|
48
|
+
else:
|
|
49
|
+
ratio = im.width / im.height
|
|
50
|
+
w = kwargs['max_width']
|
|
51
|
+
h = round(w / ratio)
|
|
52
|
+
|
|
53
|
+
im_resized = im.resize((w, h), resample=Image.LANCZOS)
|
|
54
|
+
|
|
55
|
+
# If not removing meta and set_format supports EXIF
|
|
56
|
+
if exif_data and set_format in ['jpeg', 'jpg', 'tiff']:
|
|
57
|
+
im_resized.save(
|
|
58
|
+
kwargs['image_destination'],
|
|
59
|
+
set_format,
|
|
60
|
+
optimize=True,
|
|
61
|
+
quality=kwargs['quality'],
|
|
62
|
+
exif=exif_data
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
im_resized.save(
|
|
66
|
+
kwargs['image_destination'],
|
|
67
|
+
set_format,
|
|
68
|
+
optimize=True,
|
|
69
|
+
quality=kwargs['quality']
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
except Exception as err:
|
|
73
|
+
raise err
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
|
|
3
|
+
# Simple color utility for terminal output
|
|
4
|
+
class select:
|
|
5
|
+
HEADER = '\033[95m'
|
|
6
|
+
OKBLUE = '\033[94m'
|
|
7
|
+
OKCYAN = '\033[96m'
|
|
8
|
+
OKGREEN = '\033[92m'
|
|
9
|
+
WARNING = '\033[93m'
|
|
10
|
+
FAIL = '\033[91m'
|
|
11
|
+
ENDC = '\033[0m'
|
|
12
|
+
BOLD = '\033[1m'
|
|
13
|
+
UNDERLINE = '\033[4m'
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
|
|
3
|
+
class set_default_values:
|
|
4
|
+
"""
|
|
5
|
+
Assigns a default value if the provided argument is None.
|
|
6
|
+
Usage:
|
|
7
|
+
set_default_values(default, provided)
|
|
8
|
+
"""
|
|
9
|
+
def __init__(self, *args):
|
|
10
|
+
# args[0] -> default, args[1] -> provided
|
|
11
|
+
if args[1] is None:
|
|
12
|
+
self.set = args[0]
|
|
13
|
+
else:
|
|
14
|
+
self.set = args[1]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from components import imageCompressor as iC, setConfigurations
|
|
8
|
+
|
|
9
|
+
config = setConfigurations.get_resources()
|
|
10
|
+
|
|
11
|
+
LOG_FILE = "logs.txt"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_file_list(your_folder) -> dir:
|
|
15
|
+
"""
|
|
16
|
+
Builds a generator of files from 'your_folder' that match any of the
|
|
17
|
+
supported formats in 'configurations.ini'.
|
|
18
|
+
"""
|
|
19
|
+
for root, dirs, files in os.walk(your_folder):
|
|
20
|
+
for file_bfl in files:
|
|
21
|
+
if os.path.splitext(file_bfl)[1].lower() in config['supported_formats'].values():
|
|
22
|
+
yield os.path.join(root, file_bfl)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_folder(your_path) -> None:
|
|
26
|
+
"""Creates a folder if it does not exist."""
|
|
27
|
+
if not os.path.exists(your_path):
|
|
28
|
+
os.makedirs(your_path)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def folder_size(your_folder) -> str:
|
|
32
|
+
"""Returns a string representing total folder size in KB."""
|
|
33
|
+
if not os.path.exists(your_folder):
|
|
34
|
+
return "Total folder size: 0 KB"
|
|
35
|
+
|
|
36
|
+
total_size_kb = sum(
|
|
37
|
+
file.stat().st_size for file in Path(your_folder).rglob('*') if file.is_file()
|
|
38
|
+
) / 1024
|
|
39
|
+
return f"Total folder size: {round(total_size_kb, 2)} KB"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_percentage_difference(num_a, num_b) -> float:
|
|
43
|
+
"""
|
|
44
|
+
Positive if compressed is smaller, negative if compressed is larger.
|
|
45
|
+
"""
|
|
46
|
+
if num_b == 0:
|
|
47
|
+
return 0.0
|
|
48
|
+
if num_b >= num_a:
|
|
49
|
+
return -round((abs(num_b - num_a) / num_b) * 100, 2)
|
|
50
|
+
else:
|
|
51
|
+
return round((abs(num_a - num_b) / num_b) * 100, 2)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def start_command(**kwargs) -> None:
|
|
55
|
+
"""
|
|
56
|
+
1) Creates destination folder.
|
|
57
|
+
2) Compresses/resizes.
|
|
58
|
+
3) Copies timestamps (unless remove_meta == True).
|
|
59
|
+
"""
|
|
60
|
+
src_image = kwargs['source_image']
|
|
61
|
+
before, sep, after = src_image.partition(kwargs['img_path'])
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
create_folder(kwargs['img_destination'] + os.path.dirname(after))
|
|
65
|
+
except Exception: # noqa
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
new_file_path = (
|
|
69
|
+
kwargs['img_destination']
|
|
70
|
+
+ after.replace(os.path.splitext(after)[1], '')
|
|
71
|
+
+ f".{kwargs['set_format']}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Compress
|
|
75
|
+
iC.compress_resize_image(
|
|
76
|
+
image_location=kwargs['source_image'],
|
|
77
|
+
image_destination=new_file_path,
|
|
78
|
+
set_format=kwargs['set_format'],
|
|
79
|
+
max_width=kwargs['max_width'],
|
|
80
|
+
quality=kwargs['quality'],
|
|
81
|
+
remove_meta=kwargs.get('remove_meta', False)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# If removing metadata, skip copying timestamps
|
|
85
|
+
if not kwargs.get('remove_meta', False):
|
|
86
|
+
if os.path.exists(new_file_path):
|
|
87
|
+
try:
|
|
88
|
+
shutil.copystat(src_image, new_file_path)
|
|
89
|
+
except Exception as e:
|
|
90
|
+
log_message(
|
|
91
|
+
config,
|
|
92
|
+
"error",
|
|
93
|
+
f"[{datetime.now()}] ERROR copying timestamps {new_file_path}: {e}\n"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def log_message(config, log_type: str, message: str): # noqa
|
|
98
|
+
"""
|
|
99
|
+
Logs a message if the corresponding log_<type> is True in config.
|
|
100
|
+
The log types are "success", "warning", "error".
|
|
101
|
+
"""
|
|
102
|
+
# read booleans from config
|
|
103
|
+
max_rows = int(config['logs']['max_rows'])
|
|
104
|
+
|
|
105
|
+
# For safety, using getboolean
|
|
106
|
+
log_success = config['logs'].getboolean('log_success')
|
|
107
|
+
log_errors = config['logs'].getboolean('log_errors')
|
|
108
|
+
log_warnings = config['logs'].getboolean('log_warnings')
|
|
109
|
+
|
|
110
|
+
if log_type == "success" and not log_success:
|
|
111
|
+
return
|
|
112
|
+
if log_type == "warning" and not log_warnings:
|
|
113
|
+
return
|
|
114
|
+
if log_type == "error" and not log_errors:
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
log_path = os.path.join(os.getcwd(), LOG_FILE)
|
|
118
|
+
|
|
119
|
+
# Append log
|
|
120
|
+
try:
|
|
121
|
+
with open(log_path, 'a', encoding='utf-8') as file:
|
|
122
|
+
file.write(message)
|
|
123
|
+
except Exception as e:
|
|
124
|
+
print(f"Cannot write to log file: {e}")
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
# Now enforce max_rows limit
|
|
128
|
+
try:
|
|
129
|
+
with open(log_path, 'r+', encoding='utf-8') as file:
|
|
130
|
+
lines = file.readlines()
|
|
131
|
+
if len(lines) > max_rows:
|
|
132
|
+
new_lines = lines[-max_rows:]
|
|
133
|
+
with open(log_path, 'w', encoding='utf-8') as file:
|
|
134
|
+
file.writelines(new_lines)
|
|
135
|
+
except Exception as e:
|
|
136
|
+
print(f"Error trimming log file: {e}")
|
data/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022 George Khananaev
|
|
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,184 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: py-bulk--image-compressor
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A Python bulk image compressor and resizer
|
|
5
|
+
Home-page: https://github.com/georgekhananaev/py-image-compressor
|
|
6
|
+
Author: George Khananaev
|
|
7
|
+
Author-email: george.khananaev@gmail.com
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: Pillow>=11.1.0
|
|
13
|
+
Requires-Dist: tqdm>=4.67.1
|
|
14
|
+
Requires-Dist: pillow-heif>=0.21.0
|
|
15
|
+
Requires-Dist: pillow-avif-plugin>=1.4.6
|
|
16
|
+
Dynamic: author
|
|
17
|
+
Dynamic: author-email
|
|
18
|
+
Dynamic: classifier
|
|
19
|
+
Dynamic: description
|
|
20
|
+
Dynamic: description-content-type
|
|
21
|
+
Dynamic: home-page
|
|
22
|
+
Dynamic: requires-dist
|
|
23
|
+
Dynamic: summary
|
|
24
|
+
|
|
25
|
+
# Python Bulk Image Compressor + Resizer
|
|
26
|
+
|
|
27
|
+
This Python script allows you to compress and resize images in bulk. It can optionally preserve EXIF metadata and file timestamps or remove them entirely if the `-rm y` flag is used.
|
|
28
|
+
|
|
29
|
+
### Features
|
|
30
|
+
- **Supports Multiple Formats:**
|
|
31
|
+
- **Full Metadata Preservation:** JPEG, JPG, TIFF.
|
|
32
|
+
- **Partial/No Metadata:** PNG, WEBP, BMP, etc., do not reliably store EXIF data.
|
|
33
|
+
- **Metadata Removal:** If `-rm y` is specified, all metadata (e.g., GPS, date/time, camera info) is stripped, and timestamps are reset instead of being copied from the source.
|
|
34
|
+
- **Logging:** Logs each compression operation in a `logs.txt` file. You can configure the maximum row limit for the log file in the `[logs]` section of `configurations.ini`. When the log file exceeds this limit, older rows are removed.
|
|
35
|
+
|
|
36
|
+

|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
### Installation
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+
1. Install [Python 3+](https://www.python.org/downloads/).
|
|
44
|
+
2. Install [Git](https://git-scm.com/).
|
|
45
|
+
3. Clone this repository:
|
|
46
|
+
```bash
|
|
47
|
+
git clone https://github.com/georgekhananaev/py-image-compressor.git
|
|
48
|
+
```
|
|
49
|
+
4. Install dependencies:
|
|
50
|
+
```bash
|
|
51
|
+
cd py-image-compressor
|
|
52
|
+
pip install -r requirements.txt
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
### Usage
|
|
58
|
+
|
|
59
|
+
| Command | Required | Default Value | Description | Details | Example |
|
|
60
|
+
|---------|----------|-----------------|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------|------------------------------|
|
|
61
|
+
| **-l** | Yes | N/A | Input location | Path to the folder containing original images. | `-l "C:/Original Images/"` |
|
|
62
|
+
| **-d** | No | `/data/output/` | Output location | Path where compressed images will be saved. | `-d "C:/Compressed Images/"` |
|
|
63
|
+
| **-w** | No | `1920` | Max width | Resize images to this maximum width while maintaining aspect ratio. | `-w 1920` |
|
|
64
|
+
| **-q** | No | `80` | Quality | Image quality (percentage). Lower values save more space. | `-q 80` |
|
|
65
|
+
| **-f** | No | `jpeg` | Output format | Supported formats: PNG, JPEG, JPG, PPM, GIF, TIFF, BMP, WEBP, HEIC, HEIF, AVIF. Refer to [Pillow Documentation](https://pillow.readthedocs.io/). | `-f jpeg` |
|
|
66
|
+
| **-r** | No | `no` | Remove larger files | If set to `y`, deletes compressed images if they are larger than the original. | `-r y` |
|
|
67
|
+
| **-rm** | No | `no` | Remove metadata | If set to `y`, removes all metadata (EXIF, GPS, etc.) and resets timestamps. | `-rm y` |
|
|
68
|
+
| **-h** | No | - | Help | Displays help information about commands. | `-h` or `--help` |
|
|
69
|
+
|
|
70
|
+
> **Note:** Use quotation marks for paths with spaces (e.g., `"C:/My Folder/"`).
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
### Logging
|
|
75
|
+
|
|
76
|
+
- A `logs.txt` file is created/appended in the script's directory.
|
|
77
|
+
- Maximum rows are configured in `[logs] max_rows`. Older entries are pruned when the limit is exceeded.
|
|
78
|
+
- Log levels:
|
|
79
|
+
- `log_success`
|
|
80
|
+
- `log_errors`
|
|
81
|
+
- `log_warnings`
|
|
82
|
+
- Enable or disable logging levels in `configurations.ini`.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
### Examples
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
python main.py -l "D:/Images/Originals/" -d "D:/Images/Compressed/" -f webp -w 500 -q 80 -rm y
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Example output:
|
|
93
|
+
|
|
94
|
+

|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Updates
|
|
99
|
+
|
|
100
|
+
#### **26/01/2025**
|
|
101
|
+
1. EXIF metadata is preserved if supported by the output format (e.g., JPEG).
|
|
102
|
+
2. Added support for `.avif` format (requires `pillow-avif-plugin`).
|
|
103
|
+
3. Minor speed optimizations for faster compression.
|
|
104
|
+
4. Updated `requirements.txt` with the latest package versions.
|
|
105
|
+
5. Added logs, which is exported to logs.txt
|
|
106
|
+
6. Added tests, use
|
|
107
|
+
```shell
|
|
108
|
+
pytest --maxfail=1 --disable-warnings -v
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
#### **02/12/2023**
|
|
112
|
+
1. Automatic detection and handling of RGB/RGBA channels.
|
|
113
|
+
2. Thanks to "ZenithVal" for the suggestion.
|
|
114
|
+
|
|
115
|
+
#### **06/04/2023**
|
|
116
|
+
1. Updated `README.md`.
|
|
117
|
+
2. Updated `requirements.txt` with the latest package versions.
|
|
118
|
+
|
|
119
|
+
#### **12/12/2022**
|
|
120
|
+
1. Introduced a `resources` folder containing `configurations.ini` for storing program settings.
|
|
121
|
+
2. Set up future GUI plans using `customtkinter`.
|
|
122
|
+
3. Removed PySimpleGUI from dependencies.
|
|
123
|
+
|
|
124
|
+
#### **06/12/2022**
|
|
125
|
+
1. Default values added for `-d`, `-f`, and `-q` options. You can run:
|
|
126
|
+
```bash
|
|
127
|
+
python main.py -l "C:/Your Folder/"
|
|
128
|
+
```
|
|
129
|
+
Defaults: JPEG format, 80% quality, 1920px max width, `/data/output/` as the destination folder.
|
|
130
|
+
2. Simplified the code using `*args` and `**kwargs`.
|
|
131
|
+
|
|
132
|
+
#### **05/12/2022**
|
|
133
|
+
1. Added support for `.heif` and `.heic` formats (e.g., iPhone photos).
|
|
134
|
+
2. Updated `requirements.txt` to include `pillow-heif`.
|
|
135
|
+
|
|
136
|
+
#### **04/12/2022**
|
|
137
|
+
1. Improved the output to display the actual size saved for each image, with full multithreading conversion support.
|
|
138
|
+
> 
|
|
139
|
+
2. Enhanced terminal output for better visualization of size savings.
|
|
140
|
+
3. Began GUI development for Windows and Ubuntu.
|
|
141
|
+
4. Added `-r y` to skip retaining larger compressed images.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
### Future Plans
|
|
146
|
+
- A GUI interface will be added if this project receives 50+ stars on GitHub.
|
|
147
|
+
- Integration with cloud storage services like AWS S3 and Google Drive for automatic uploads.
|
|
148
|
+
- Support for more advanced image editing options, such as cropping and watermarking.
|
|
149
|
+
|
|
150
|
+
> **Personal Note:** I use this script for compressing images for React-based websites and converting entire folders to WebP format with a single command.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Contributing 🤝
|
|
155
|
+
|
|
156
|
+
We’d love your help to make py-image-compressor even better! To contribute:
|
|
157
|
+
|
|
158
|
+
1. Fork the repository.
|
|
159
|
+
2. Create a new branch for your feature: `git checkout -b feature-name`.
|
|
160
|
+
3. Commit your changes: `git commit -m "Add awesome feature"`.
|
|
161
|
+
4. Push your branch: `git push origin feature-name`.
|
|
162
|
+
5. Open a pull request and tell us about your changes.
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
py-image-compressor is licensed under the MIT License. See the `LICENSE` file for details.
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Need Help?
|
|
173
|
+
|
|
174
|
+
If you run into any issues or have questions, feel free to [open an issue](https://github.com/georgekhananaev/py-image-compressor/issues).
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
## Support Me
|
|
178
|
+
|
|
179
|
+
If you find my work helpful, consider supporting me by buying me a coffee at [Buy Me A Coffee](https://www.buymeacoffee.com/georgekhananaev).
|
|
180
|
+
Your support helps me continue to create and maintain useful projects.
|
|
181
|
+
|
|
182
|
+
[](https://www.buymeacoffee.com/georgekhananaev)
|
|
183
|
+
|
|
184
|
+
Thank you!
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
components/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
components/imageCompressor.py,sha256=qMWARZrupo_Tj7EkekGxqrqW6TtxDTSVYoMaIdZvbaQ,2333
|
|
3
|
+
components/localColors.py,sha256=RVS7gvSPPR0DJuFfcgeNSEg-9nLUhOv_tfyAHaOu3SI,288
|
|
4
|
+
components/mainClasses.py,sha256=ngL5temf3hC_acL0gUP7cuFPxtZQ8KdA5EpX-vvzTCI,365
|
|
5
|
+
components/mainFunctions.py,sha256=1LlLy12ZVUeOBAj8Kubey8FQdGcwoE4Yq7XxuqNAvOQ,4159
|
|
6
|
+
components/setConfigurations.py,sha256=mZdoBrpfYhM2HXqirkS7weaKWt0qvl8lBBfH38Zk5wY,261
|
|
7
|
+
data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
tests/test_imageCompressor.py,sha256=B6S7-VMy-T13fgHFlKu0_viFyA390ljEuJ8Jhckwfu0,1028
|
|
10
|
+
tests/test_main.py,sha256=6OI9OMyQkGCnHcVT249meVqU2RFk87kJ7HtYQtmB6Ns,897
|
|
11
|
+
tests/test_mainFunctions.py,sha256=dm8Nzr51gD8x0VsB-ZwV0is9DvofaBfq0xcpHpLltz8,1209
|
|
12
|
+
tests/test_setConfigurations.py,sha256=IEbdkyEPf03VZ4WhF96z1fQZbTYUUrBlfmJNEf7otjQ,416
|
|
13
|
+
py_bulk_image_compressor-1.0.0.dist-info/LICENSE,sha256=gUujiUx8F-4IJKvnKzPGhhHnrRoMBOcwN_EEDZt6TKE,1073
|
|
14
|
+
py_bulk_image_compressor-1.0.0.dist-info/METADATA,sha256=SrjDkSPUgXFWUcIVpqVimN934M7Khp5u9NfK-daKEQA,8356
|
|
15
|
+
py_bulk_image_compressor-1.0.0.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
|
16
|
+
py_bulk_image_compressor-1.0.0.dist-info/top_level.txt,sha256=THioVdUbPSf1b-w7rr4aH13xcJH5BrQIxnH_EXsYDUc,22
|
|
17
|
+
py_bulk_image_compressor-1.0.0.dist-info/RECORD,,
|
tests/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pytest
|
|
3
|
+
from PIL import Image
|
|
4
|
+
from components import imageCompressor as ic
|
|
5
|
+
|
|
6
|
+
@pytest.mark.parametrize("img_format", ["jpeg", "png", "webp"])
|
|
7
|
+
def test_compress_resize_image(tmp_path, img_format):
|
|
8
|
+
"""
|
|
9
|
+
Using a small sample image in tests/sample_images,
|
|
10
|
+
compress/resize it and ensure output file is created.
|
|
11
|
+
"""
|
|
12
|
+
# Prepare sample image path
|
|
13
|
+
sample_dir = os.path.join(os.path.dirname(__file__), "sample_images")
|
|
14
|
+
sample_image = os.path.join(sample_dir, "sample.jpeg")
|
|
15
|
+
|
|
16
|
+
# Output path
|
|
17
|
+
output_image = tmp_path / f"compressed.{img_format}"
|
|
18
|
+
|
|
19
|
+
# Call compressor
|
|
20
|
+
ic.compress_resize_image(
|
|
21
|
+
image_location=sample_image,
|
|
22
|
+
image_destination=str(output_image),
|
|
23
|
+
set_format=img_format,
|
|
24
|
+
max_width=500,
|
|
25
|
+
quality=80,
|
|
26
|
+
remove_meta=False
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Check if file is created
|
|
30
|
+
assert output_image.exists()
|
|
31
|
+
|
|
32
|
+
# Check if it can be opened by Pillow
|
|
33
|
+
with Image.open(output_image) as img:
|
|
34
|
+
assert img.width <= 500 # we used max_width=500
|
tests/test_main.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
def test_help_flag():
|
|
7
|
+
"""
|
|
8
|
+
Ensure the script runs with -h and does not crash.
|
|
9
|
+
We'll capture stdout to verify it contains 'usage' or 'help'.
|
|
10
|
+
"""
|
|
11
|
+
cmd = [sys.executable, "main.py", "-h"]
|
|
12
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
13
|
+
assert result.returncode == 0
|
|
14
|
+
assert "usage" in result.stdout.lower() or "help" in result.stdout.lower()
|
|
15
|
+
|
|
16
|
+
@pytest.mark.parametrize("flag", ["-l", "-d"])
|
|
17
|
+
def test_missing_required_args(flag):
|
|
18
|
+
"""
|
|
19
|
+
Test that missing required argument -l triggers an error.
|
|
20
|
+
We'll check for returncode != 0.
|
|
21
|
+
"""
|
|
22
|
+
# When we run the script without -l or incomplete arguments, it should fail
|
|
23
|
+
cmd = [sys.executable, "main.py"]
|
|
24
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
25
|
+
assert result.returncode != 0
|
|
26
|
+
assert "error" in result.stderr.lower()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pytest
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from components import mainFunctions as mf
|
|
5
|
+
|
|
6
|
+
def test_create_folder(tmp_path):
|
|
7
|
+
"""Test that create_folder() actually creates the given folder."""
|
|
8
|
+
test_dir = tmp_path / "test_folder"
|
|
9
|
+
assert not test_dir.exists()
|
|
10
|
+
mf.create_folder(str(test_dir))
|
|
11
|
+
assert test_dir.exists()
|
|
12
|
+
|
|
13
|
+
def test_folder_size(tmp_path):
|
|
14
|
+
"""Test folder_size() returns correct size in KB."""
|
|
15
|
+
test_dir = tmp_path / "test_folder2"
|
|
16
|
+
mf.create_folder(str(test_dir))
|
|
17
|
+
|
|
18
|
+
# Create a small file (1KB of data)
|
|
19
|
+
file_path = test_dir / "dummy.txt"
|
|
20
|
+
with open(file_path, "wb") as f:
|
|
21
|
+
f.write(b'X' * 1024)
|
|
22
|
+
|
|
23
|
+
result = mf.folder_size(str(test_dir))
|
|
24
|
+
# Should be around 1KB. We'll just check if the substring is in the output
|
|
25
|
+
assert "1.0 KB" in result or "1.0" in result
|
|
26
|
+
|
|
27
|
+
def test_get_percentage_difference():
|
|
28
|
+
"""Check the logic for get_percentage_difference."""
|
|
29
|
+
# original_size=100, new_size=50 => saves 100%
|
|
30
|
+
diff = mf.get_percentage_difference(num_a=100, num_b=50)
|
|
31
|
+
assert diff == 100.0
|
|
32
|
+
|
|
33
|
+
# original=50, new=100 => -50% (i.e., the new is bigger by 50%)
|
|
34
|
+
diff = mf.get_percentage_difference(num_a=50, num_b=100)
|
|
35
|
+
assert diff == -50.0
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from components import setConfigurations
|
|
3
|
+
|
|
4
|
+
def test_get_resources():
|
|
5
|
+
"""Check if configurations.ini is loaded properly."""
|
|
6
|
+
config = setConfigurations.get_resources()
|
|
7
|
+
# We expect certain sections to exist
|
|
8
|
+
assert 'default_parameters' in config
|
|
9
|
+
assert 'supported_formats' in config
|
|
10
|
+
assert 'logs' in config
|
|
11
|
+
|
|
12
|
+
# Check a known key
|
|
13
|
+
assert 'max_width' in config['default_parameters']
|