Skip to content

Commit a2f82ba

Browse files
committed
Rename test data. Update common.py with tests
1 parent e15806f commit a2f82ba

File tree

8 files changed

+166
-51
lines changed

8 files changed

+166
-51
lines changed

README.md

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
## batch_img
22

33
Batch processing image files by utilizing **[Pillow / PIL](https://github.com/python-pillow/Pillow)** library.
4+
Resize, rotate, add border or do default actions on a single image file or all image files in a folder.
5+
Tested these image file formats (**HEIC, JPG, PNG**) on macOS.
46

57
### Usage
68

@@ -43,11 +45,13 @@ Usage: batch_img border [OPTIONS] SRC_PATH
4345
Add border to image file(s)
4446
4547
Options:
46-
-bw, --border_width INTEGER Add border to image file(s) with the
47-
border_width. 0 - no border [default: 5]
48-
-bc, --border_color TEXT Add border to image file(s) with the
49-
border_color string [default: gray]
50-
--help Show this message and exit.
48+
-bw, --border_width INTEGER RANGE
49+
Add border to image file(s) with the
50+
border_width. 0 - no border [default: 5;
51+
x>=0]
52+
-bc, --border_color TEXT Add border to image file(s) with the
53+
border_color string [default: gray]
54+
--help Show this message and exit.
5155
```
5256

5357
#### The `defaults` sub-command CLI options:
@@ -72,9 +76,9 @@ Usage: batch_img resize [OPTIONS] SRC_PATH
7276
Resize image file(s)
7377
7478
Options:
75-
-l, --length INTEGER Resize image file(s) on current aspect ratio to the
76-
length. 0 - no resize [default: 0]
77-
--help Show this message and exit.
79+
-l, --length INTEGER RANGE Resize image file(s) on original aspect ratio to
80+
the length. 0 - no resize [default: 0; x>=0]
81+
--help Show this message and exit.
7882
```
7983

8084
#### The `rotate` sub-command CLI options:
@@ -86,7 +90,7 @@ Usage: batch_img rotate [OPTIONS] SRC_PATH
8690
Rotate image file(s)
8791
8892
Options:
89-
-d, --degree INTEGER Rotate image file(s) to the degree clock-wise. 0 - no
90-
rotate [default: 0]
91-
--help Show this message and exit.
93+
-d, --degree INTEGER RANGE Rotate image file(s) to the degree clock-wise. 0
94+
- no rotate [default: 0; x>=0]
95+
--help Show this message and exit.
9296
```

batch_img/common.py

Lines changed: 57 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import pillow_heif
1515
from loguru import logger
1616
from PIL import Image, ImageChops
17+
from PIL.TiffImagePlugin import IFDRational
1718

1819
from batch_img.const import PKG_NAME, TS_FORMAT, VER
1920

@@ -90,7 +91,9 @@ def decode_exif(exif_data: str) -> dict:
9091
exif_dict = piexif.load(exif_data)
9192
_dict = {}
9293
for ifd_name, val in exif_dict.items():
93-
if not val:
94+
# Canon EOS 5D Mark II 'thumbnail': : b'\xff\xd8\xff\xdb...
95+
# 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte
96+
if not val or ifd_name == "thumbnail":
9497
continue
9598
for tag_id, value in val.items():
9699
tag_name = piexif.TAGS[ifd_name].get(tag_id, {}).get("name", tag_id)
@@ -103,6 +106,7 @@ def decode_exif(exif_data: str) -> dict:
103106
"SubjectArea",
104107
"Software",
105108
"HostComputer",
109+
"UserComment",
106110
):
107111
if key in _dict:
108112
_dict.pop(key)
@@ -128,7 +132,49 @@ def decode_exif(exif_data: str) -> dict:
128132
return _res
129133

130134
@staticmethod
131-
def are_images_equal(path1: Path | str, path2: Path | str) -> bool:
135+
def get_image_data(file: Path) -> tuple:
136+
"""Get image file data
137+
138+
Args:
139+
file: image file path
140+
141+
Returns:
142+
tuple: data, info
143+
"""
144+
size = getsize(file)
145+
m_ts = datetime.fromtimestamp(getmtime(file)).strftime(TS_FORMAT)
146+
with Image.open(file) as img:
147+
data = img.convert("RGB")
148+
d_info = {
149+
"file_size": Common.readable_file_size(size),
150+
"file_ts": m_ts,
151+
"format": img.format,
152+
"mode": img.mode,
153+
"size": img.size,
154+
"info": img.info,
155+
}
156+
for key in ("icc_profile", "xmp"):
157+
if key in img.info:
158+
img.info.pop(key)
159+
if "exif" in img.info:
160+
exif_data = img.info.pop("exif")
161+
d_info["exif"] = Common.decode_exif(exif_data)
162+
163+
return data, d_info
164+
165+
@staticmethod
166+
def jsn_serial(obj):
167+
"""JSON serializer for objects not serializable by default json code"""
168+
if isinstance(obj, IFDRational):
169+
return float(obj)
170+
if isinstance(obj, bytes):
171+
return obj.decode()
172+
raise TypeError(
173+
f"Object of type {obj.__class__.__name__} is not JSON serializable"
174+
)
175+
176+
@staticmethod
177+
def are_images_equal(path1: Path, path2: Path) -> bool:
132178
"""Check if two image files are visually equal pixel-wise
133179
134180
Args:
@@ -138,36 +184,13 @@ def are_images_equal(path1: Path | str, path2: Path | str) -> bool:
138184
Returns:
139185
bool: True - visually equal, False - not visually equal
140186
"""
141-
size1 = getsize(path1)
142-
m_ts1 = datetime.fromtimestamp(getmtime(path1)).strftime(TS_FORMAT)
143-
with Image.open(path1) as img1:
144-
data1 = img1.convert("RGB")
145-
meta1 = {
146-
"file_size": Common.readable_file_size(size1),
147-
"file_ts": m_ts1,
148-
"format": img1.format,
149-
"size": img1.size,
150-
"mode": img1.mode,
151-
"info": img1.info,
152-
}
153-
if "exif" in img1.info:
154-
meta1["info"] = Common.decode_exif(img1.info["exif"])
155-
156-
size2 = getsize(path2)
157-
m_ts2 = datetime.fromtimestamp(getmtime(path2)).strftime(TS_FORMAT)
158-
with Image.open(path2) as img2:
159-
data2 = img2.convert("RGB")
160-
meta2 = {
161-
"file_size": Common.readable_file_size(size2),
162-
"file_ts": m_ts2,
163-
"format": img2.format,
164-
"size": img2.size,
165-
"mode": img2.mode,
166-
"info": img2.info,
167-
}
168-
if "exif" in img2.info:
169-
meta2["info"] = Common.decode_exif(img2.info["exif"])
170-
171-
logger.info(f"Meta of {path1}:\n{json.dumps(meta1, indent=2)}")
172-
logger.info(f"Meta of {path2}:\n{json.dumps(meta2, indent=2)}")
187+
data1, meta1 = Common.get_image_data(path1)
188+
data2, meta2 = Common.get_image_data(path2)
189+
190+
logger.info(
191+
f"{path1}:\n{json.dumps(meta1, indent=2, default=Common.jsn_serial)}"
192+
)
193+
logger.info(
194+
f"{path2}:\n{json.dumps(meta2, indent=2, default=Common.jsn_serial)}"
195+
)
173196
return ImageChops.difference(data1, data2).getbbox() is None

batch_img/interface.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def cli(ctx, version): # pragma: no cover
2828
"--border_width",
2929
default=5,
3030
show_default=True,
31+
type=click.IntRange(min=0),
3132
help="Add border to image file(s) with the border_width. 0 - no border",
3233
)
3334
@click.option(
@@ -81,8 +82,8 @@ def defaults(src_path):
8182
is_flag=False,
8283
default=0,
8384
show_default=True,
84-
type=int,
85-
help="Resize image file(s) on current aspect ratio to the length. 0 - no resize",
85+
type=click.IntRange(min=0),
86+
help="Resize image file(s) on original aspect ratio to the length. 0 - no resize",
8687
)
8788
def resize(src_path, length):
8889
options = {
@@ -105,7 +106,7 @@ def resize(src_path, length):
105106
is_flag=False,
106107
default=0,
107108
show_default=True,
108-
type=int,
109+
type=click.IntRange(min=0),
109110
help="Rotate image file(s) to the degree clock-wise. 0 - no rotate",
110111
)
111112
def rotate(src_path, degree):

batch_img/resize.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ def resize_an_image(in_path: Path, out_path: Path, length: int) -> tuple:
4646
# Save to the same format: HEIF, JPEG, PNG, etc.
4747
if exif_dict:
4848
exif_bytes = piexif.dump(exif_dict)
49-
img.save(out_file, format=img.format, exif=exif_bytes)
49+
img.save(out_file, img.format, optimize=True, exif=exif_bytes)
5050
else:
51-
img.save(out_file, format=img.format)
51+
img.save(out_file, img.format, optimize=True)
5252
logger.info(f"Saved {out_file}")
5353
return True, out_file
5454
except (AttributeError, FileNotFoundError, ValueError) as e:

tests/data/mixed/Cartoon.heic

44.6 KB
Binary file not shown.

tests/test_common.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"""
55

66
import json
7+
from os.path import dirname
8+
from pathlib import Path
79
from unittest.mock import MagicMock, patch
810

911
import pytest
@@ -144,7 +146,7 @@ def test_json_dump():
144146
"WhiteBalance": 0,
145147
"FocalLengthIn35mmFilm": 24,
146148
},
147-
)
149+
),
148150
]
149151
)
150152
def data_decode_exif(request):
@@ -155,3 +157,68 @@ def test_decode_exif(data_decode_exif):
155157
exif_data, expected = data_decode_exif
156158
actual = Common.decode_exif(exif_data)
157159
assert actual == expected
160+
161+
162+
@pytest.fixture(
163+
params=[
164+
(
165+
Path(f"{dirname(__file__)}/data/HEIC/Cartoon.heic"),
166+
{
167+
"file_size": "44.6 KB",
168+
"file_ts": "2025-08-16_23-44-21",
169+
"format": "HEIF",
170+
"mode": "RGB",
171+
"size": (758, 758),
172+
"info": {
173+
"aux": {},
174+
"bit_depth": 8,
175+
"chroma": 420,
176+
"depth_images": [],
177+
"icc_profile_type": "prof",
178+
"metadata": [],
179+
"original_orientation": None,
180+
"primary": True,
181+
"thumbnails": [],
182+
},
183+
"exif": {"ExifTag": 90, "Orientation": 1},
184+
},
185+
)
186+
]
187+
)
188+
def data_get_image(request):
189+
return request.param
190+
191+
192+
def test_get_image_data(data_get_image):
193+
file, expected = data_get_image
194+
actual = Common.get_image_data(file)
195+
assert actual[1] == expected
196+
197+
198+
@pytest.fixture(
199+
params=[
200+
(
201+
Path(f"{dirname(__file__)}/data/HEIC/IMG_0070.HEIC"),
202+
Path(f"{dirname(__file__)}/data/HEIC/IMG_0070.HEIC"),
203+
True,
204+
),
205+
(
206+
Path(f"{dirname(__file__)}/data/PNG/Checkmark.PNG"),
207+
Path(f"{dirname(__file__)}/data/PNG/LagrangePoints.png"),
208+
False,
209+
),
210+
(
211+
Path(f"{dirname(__file__)}/data/JPG/152.JPG"),
212+
Path(f"{dirname(__file__)}/data/JPG/P1040566.jpeg"),
213+
False,
214+
),
215+
]
216+
)
217+
def data_are_images_equal(request):
218+
return request.param
219+
220+
221+
def test_are_images_equal(data_are_images_equal):
222+
path1, path2, expected = data_are_images_equal
223+
actual = Common.are_images_equal(path1, path2)
224+
assert actual == expected

tests/test_interface.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,16 @@ def test_resize(mock_resize, data_resize):
7878
assert result.output == expected
7979

8080

81+
@patch("batch_img.main.Main.resize")
82+
def test_error_resize(mock_resize):
83+
_input = "img/file --length -9"
84+
mock_resize.return_value = True
85+
runner = CliRunner()
86+
result = runner.invoke(resize, args=_input.split())
87+
print(result.output)
88+
assert result.exception
89+
90+
8191
@pytest.fixture(
8292
params=[
8393
("src_path -d 90", True, MSG_OK),
@@ -98,3 +108,13 @@ def test_rotate(mock_rotate, data_rotate):
98108
print(result.output)
99109
assert not result.exception
100110
assert result.output == expected
111+
112+
113+
@patch("batch_img.main.Main.rotate")
114+
def test_error_rotate(mock_rotate):
115+
_input = "img/file --degree -90"
116+
mock_rotate.return_value = True
117+
runner = CliRunner()
118+
result = runner.invoke(rotate, args=_input.split())
119+
print(result.output)
120+
assert result.exception

0 commit comments

Comments
 (0)