From 7e65ac7ed95e4174561f463abc3658d380bf0117 Mon Sep 17 00:00:00 2001 From: John Liu Date: Sun, 30 Aug 2026 21:13:16 -0700 Subject: [PATCH 1/5] Set prod logging when building dist. Set version to 1.4.3 --- .github/workflows/release.yml | 9 ++++++++- Makefile | 8 ++++++-- pyproject.toml | 2 +- tests/test_common.py | 6 +++--- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef45441..213923e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,14 @@ jobs: - name: Build source and wheel dist # Uses hatchling as defined in pyproject.toml - run: python3 -m build + run: | + echo "==> Set prod logging" + mv batch_img/config.json batch_img/config_bk.json + cp -p batch_img/config_prod.json batch_img/config.json + echo "Build by hatchling defined in pyproject.toml" + python3 -m build + echo "==> Restore config.json" + mv batch_img/config_bk.json batch_img/config.json - name: Validate dist files run: twine check dist/* diff --git a/Makefile b/Makefile index 512d177..f18c90e 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,6 @@ # make check # validate the distribution files # make install-local # install into current venv and smoke-test -PYTHON_FILES = batch_img VERSION ?= $(shell python -c "from batch_img.const import __version__; print(__version__)" 2>/dev/null || echo "0.0.0") clean: @@ -18,16 +17,21 @@ clean: rm -fr docs/build out_*.yaml tmp_* lint: clean - pylint $(PYTHON_FILES) --ignore=venv,tests + pylint batch_img --ignore=venv,tests ruff check --fix --exit-non-zero-on-fix test: lint pytest --cov-report=term --cov=batch_img tests dist: clean + @echo "==> Set prod logging" + mv batch_img/config.json batch_img/config_bk.json + cp -p batch_img/config_prod.json batch_img/config.json # Build wheel and sdist packages python -m build --sdist @echo "Distribution files created in ./dist/" + @echo "==> Restore config.json" + mv batch_img/config_bk.json batch_img/config.json check: dist # Validate the distribution files diff --git a/pyproject.toml b/pyproject.toml index bf35dc1..90e60a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["hatchling"] [project] name = "batch_img" -version = "1.4.2" +version = "1.4.3" description = "Batch process (resize, rotate, remove background, remove GPS, add border, set transparency, auto do all) image files (HEIC, JPG, PNG)" readme = "README.md" authors = [{ name = "John Liu", email = "rim2rim@gmail.com" }] diff --git a/tests/test_common.py b/tests/test_common.py index e740eb9..46d654f 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -33,7 +33,7 @@ def os_platform(request): return request.param -@pytest.fixture(params=[(PKG_NAME, "1.4.2"), ("", "1.4.2")]) +@pytest.fixture(params=[(PKG_NAME, "1.4.3"), ("", "1.4.3")]) def ver_data(request): return request.param @@ -49,7 +49,7 @@ def test_get_version(ver_data): ( "1.9.9", PKG_NAME, - f"🔔 Update available: 1.4.2 → 1.9.9\nRun '{PKG_NAME} --update'", + f"🔔 Update available: 1.4.3 → 1.9.9\nRun '{PKG_NAME} --update'", ), ] ) @@ -67,7 +67,7 @@ def test_check_latest_version(mock_get_latest_pypi, data_check_latest_version): @pytest.fixture( params=[ - (PKG_NAME, 0, "1.4.1"), + (PKG_NAME, 0, "1.4.2"), ("bad_bogus", 1, UNKNOWN), ] ) From fb09246690b03cc0e6760d5737870db003813f99 Mon Sep 17 00:00:00 2001 From: John Liu Date: Mon, 31 Aug 2026 12:04:57 -0700 Subject: [PATCH 2/5] Improve info.py --- .pylintrc | 3 +- batch_img/info.py | 102 ++++++++++++++++++++------------- pyproject.toml | 1 + tests/data/JPG/IMG_4412.jpeg | Bin 0 -> 34272 bytes tests/data/JPG/meta_no_gps.txt | 15 +++++ tests/test_common.py | 31 +++++++++- tests/test_info.py | 55 +++++++++++++++++- 7 files changed, 164 insertions(+), 43 deletions(-) create mode 100644 tests/data/JPG/IMG_4412.jpeg create mode 100644 tests/data/JPG/meta_no_gps.txt diff --git a/.pylintrc b/.pylintrc index 8ca4ce2..77b4471 100644 --- a/.pylintrc +++ b/.pylintrc @@ -11,4 +11,5 @@ ignore-paths=^(.*/)?tests?(/.*)?$ disable=missing-function-docstring, missing-class-docstring, R0801, - E1101 + E1101, + too-many-branches diff --git a/batch_img/info.py b/batch_img/info.py index 9ea9406..c36fe37 100644 --- a/batch_img/info.py +++ b/batch_img/info.py @@ -75,7 +75,7 @@ def do_output(success_cnt: int, total: int, results: dict, quiet: bool) -> bool: output_file = Info.exif_output_path() try: with open(output_file, "w", encoding="utf-8") as output: - Info._write_formatted_info(output, total, results) + Info.write_formatted_info(output, total, results) log.info(f"EXIF information written to {output_file}") return success_cnt == total except OSError as exc: @@ -83,11 +83,8 @@ def do_output(success_cnt: int, total: int, results: dict, quiet: bool) -> bool: return False # Print to stdout - idx = 1 - for file, data in results.items(): - Info._output_exif_info(file, data, idx, total) - idx += 1 - log.info(f"\nRead meta info from {success_cnt}/{total} files") + Info.write_formatted_info(None, total, results) + log.info(f"Read meta info from {success_cnt}/{total} files") return success_cnt == total @staticmethod @@ -140,68 +137,82 @@ def read_exif(in_path: Path, quiet: bool = False) -> bool: return Info.do_output(success_count, total, results, quiet) @staticmethod - def _write_formatted_info(output: TextIO, total: int, results: dict) -> None: + def write_formatted_info(obj: TextIO | None, total: int, results: dict) -> None: """Write formatted EXIF information to a file. Args: - output: File object to write to + obj: File object to write to. If None, prints to logger. total: total input files count results: Dictionary of file paths to results """ idx = 1 for file, data in results.items(): - Info._output_exif_info(file, data, idx, total, output) + Info.out_meta_info(file, data, idx, total, obj) idx += 1 @staticmethod - def _output_exif_info( - file: Path, data: dict, index: int, total: int, output: TextIO | None = None + def _out(text: str, obj: TextIO | None = None) -> None: + """Helper to route string output dynamically. + + Args: + text: Text to write + obj: File object to write to. If None, prints to logger. + """ + if obj: + obj.write(text + "\n") + else: + log.info(text) + + @staticmethod + def out_meta_info( + file: Path, data: dict, index: int, total: int, obj: TextIO | None ) -> None: - """Format and output EXIF information to either a file or stdout. + """Format and output meta information to a file or stdout. Args: file: Image file path data: Dictionary containing file_info and exif data index: Current file index (1-based) total: Total number of files - output: File object to write to. If None, prints to logger. + obj: File object to write to. If None, prints to logger. """ - - def _out(text: str) -> None: - """Helper to route string output dynamically.""" - if output: - output.write(text + "\n") - else: - log.info(text) - file_info = data.get("file_info", {}) exif = data.get(EXIF, {}) # Output separator and file header - _out("─" * 60) - _out(f"{file} [{index}/{total}]") + Info._out("─" * 60, obj) + Info._out(f"{file} [{index}/{total}]", obj) # Output file info - _out(f" File Size : {file_info.get('file_size', UNKNOWN)}") - _out(f" Last Modified : {file_info.get('last_modified', UNKNOWN)}") - _out(f" Format : {file_info.get('format', UNKNOWN)}") - _out(f" Dimensions : {file_info.get('dimensions', UNKNOWN)}") - _out(f" Bit Depth : {file_info.get('bit_depth', UNKNOWN)}") - _out(f" Alpha Channel : {file_info.get('alpha_channel', UNKNOWN)}") - _out(f" Colorspace : {file_info.get('colorspace', UNKNOWN)}") - _out(f" Chroma Format : {file_info.get('chroma_format', UNKNOWN)}") + Info._out(f" File Size : {file_info.get('file_size', UNKNOWN)}", obj) + Info._out(f" Last Modified : {file_info.get('last_modified', UNKNOWN)}", obj) + Info._out(f" Format : {file_info.get('format', UNKNOWN)}", obj) + Info._out(f" Dimensions : {file_info.get('dimensions', UNKNOWN)}", obj) + Info._out(f" Bit Depth : {file_info.get('bit_depth', UNKNOWN)}", obj) + Info._out(f" Alpha Channel : {file_info.get('alpha_channel', UNKNOWN)}", obj) + Info._out(f" Colorspace : {file_info.get('colorspace', UNKNOWN)}", obj) + Info._out(f" Chroma Format : {file_info.get('chroma_format', UNKNOWN)}", obj) # Output EXIF metadata - _out("") - _out(" [ EXIF Metadata ]") + Info._out("", obj) + Info._out(" [ EXIF Metadata ]", obj) if not exif: - _out(" None (or unreadable EXIF header)") - _out("") + Info._out(" None (or unreadable EXIF header)", obj) + Info._out("", obj) return + Info.print_exif(exif, obj) + @staticmethod + def print_exif(exif: dict, obj: TextIO | None = None) -> None: + """Print EXIF data + + Args: + exif: exif data in dict + obj: File object to write to. If None, prints to logger. + """ # Map EXIF tags to friendly names - exif_map = { + label_map = { "Make": "Make", "Model": "Model", "DateTime": "Date/Time", @@ -211,27 +222,38 @@ def _out(text: str) -> None: "FocalLength": "Focal Length", "GPSLatitude": "GPS Data", } - for key, label in exif_map.items(): + found_any = False + for key, label in label_map.items(): value = exif.get(key, None) if key == "GPSLatitude": - value = "Present" if value else "None" + value = "Present" if value else "Absent" elif key == "ExposureTime" and isinstance(value, tuple): value = f"{value[0]}/{value[1]} s" + found_any = True elif key == "ExposureTime" and isinstance(value, float): + found_any = True if value < 1: value = f"1/{int(1 / value)} s" else: value = f"{value} s" elif key == "FNumber" and isinstance(value, tuple): value = f"f/{value[0] / value[1]:.2f}".rstrip("0").rstrip(".") + found_any = True elif key == "FNumber" and isinstance(value, (float, int)): value = f"f/{value:.2f}".rstrip("0").rstrip(".") + found_any = True elif key == "FocalLength" and isinstance(value, tuple): value = f"{value[0] / value[1]:.2f} mm" + found_any = True elif key == "FocalLength" and isinstance(value, (float, int)): value = f"{value:.2f} mm" + found_any = True elif key == "ISOSpeedRatings" and isinstance(value, (int, str)): value = f"ISO {value}" + found_any = True - _out(f" {label:<15}: {value}") - _out("") + if value: + Info._out(f" {label:<15}: {value}", obj) + if not found_any: + Info._out(" No standard camera tags found in EXIF", obj) + Info._out("", obj) diff --git a/pyproject.toml b/pyproject.toml index 90e60a4..99a444d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,7 @@ lint.ignore = [ "RET504", "PLR2004", # Magic value used in comparison, consider replacing `3` with a constant variable "PLW0717", # too-many-statements-in-try-clause + "PLR0912", ] lint.fixable = ["E", "F", "I", "UP"] exclude = [ diff --git a/tests/data/JPG/IMG_4412.jpeg b/tests/data/JPG/IMG_4412.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..2cecae3fe92b0fb54c19bc0229de137264d5e38f GIT binary patch literal 34272 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 cgJv=|> Date: Mon, 31 Aug 2026 13:04:31 -0700 Subject: [PATCH 3/5] Add exif.py to get meta from raw bytes --- batch_img/common.py | 17 ++- batch_img/exif.py | 226 +++++++++++++++++++++++++++++++++ tests/data/JPG/meta_no_gps.txt | 4 +- tests/test_common.py | 2 + tests/test_exif.py | 112 ++++++++++++++++ tests/test_info.py | 8 +- 6 files changed, 360 insertions(+), 9 deletions(-) create mode 100644 batch_img/exif.py create mode 100644 tests/test_exif.py diff --git a/batch_img/common.py b/batch_img/common.py index 0c42692..de0d673 100644 --- a/batch_img/common.py +++ b/batch_img/common.py @@ -37,6 +37,7 @@ UNKNOWN, VER, ) +from batch_img.exif import Exif from batch_img.log import Log pillow_heif.register_heif_opener() @@ -305,7 +306,7 @@ def sort_nested_dict(data): @staticmethod def get_image_data(file: Path) -> tuple: - """Get image file data + """Get image file data combining raw byte header parsing and Pillow Args: file: image file path @@ -315,21 +316,31 @@ def get_image_data(file: Path) -> tuple: """ size = getsize(file) m_ts = datetime.fromtimestamp(getmtime(file)).strftime(TS_2_MINUTE) + with open(file, "rb") as f: + raw_bytes = f.read(65536) + raw_meta = Exif.parse_raw_header(raw_bytes) + with Image.open(file) as img: data = img.convert("RGB") d_info = { "file_size": f"{Common.easy_file_sz(size)} ({size} bytes)", "file_ts": m_ts, "format": img.format, - "mode": img.mode, + "mode": raw_meta.get("mode") or img.mode, "size": img.size, "info": img.info, } + if "bit_depth" in raw_meta: + d_info["info"]["bit_depth"] = raw_meta["bit_depth"] + if "chroma" in raw_meta: + d_info["info"]["chroma"] = raw_meta["chroma"] + for key in ("icc_profile", "xmp"): if key in img.info: img.info.pop(key) + val = img.info.get("chroma", None) - if val: # Convert 420 to "4:2:0" + if val and isinstance(val, int): # Convert 420 to "4:2:0" img.info["chroma"] = ":".join(str(val)) exif_data = img.info.pop(EXIF, None) # safely ignor non-exist key if exif_data: diff --git a/batch_img/exif.py b/batch_img/exif.py new file mode 100644 index 0000000..e202d0b --- /dev/null +++ b/batch_img/exif.py @@ -0,0 +1,226 @@ +"""Raw binary header parser for image metadata extraction. +Copyright © 2026 - Present, John Liu +""" + +import struct + +from batch_img.const import UNKNOWN + + +# pylint: disable=too-few-public-methods +class Exif: + """Extract metadata directly from raw image header bytes.""" + + @staticmethod + def _parse_png(data: bytes) -> dict: + if not data.startswith(b"\x89PNG\r\n\x1a\n") or len(data) < 26: + return {} + w, h, bit_depth, color_type = struct.unpack(">IIBB", data[16:26]) + color_modes = {0: "L", 2: "RGB", 3: "P", 4: "LA", 6: "RGBA"} + return { + "format": "PNG", + "size": (w, h), + "bit_depth": bit_depth, + "mode": color_modes.get(color_type, UNKNOWN), + } + + @staticmethod + def _parse_jpeg_chroma(comp: bytes) -> str | None: + y_samp, cb_samp, cr_samp = comp[1], comp[4], comp[7] + chroma_map = { + ((2, 2), (1, 1), (1, 1)): "4:2:0", + ((2, 1), (1, 1), (1, 1)): "4:2:2", + ((1, 1), (1, 1), (1, 1)): "4:4:4", + ((1, 2), (1, 1), (1, 1)): "4:4:0", + } + key = ( + (y_samp >> 4, y_samp & 0x0F), + (cb_samp >> 4, cb_samp & 0x0F), + (cr_samp >> 4, cr_samp & 0x0F), + ) + return chroma_map.get(key) + + @staticmethod + def _parse_jpeg(data: bytes) -> dict: + if not data.startswith(b"\xff\xd8"): + return {} + + offset = 2 + data_len = len(data) + sof_markers = { + 0xC0, + 0xC1, + 0xC2, + 0xC3, + 0xC5, + 0xC6, + 0xC7, + 0xC9, + 0xCA, + 0xCB, + 0xCD, + 0xCE, + 0xCF, + } + + while offset < data_len - 1: + if data[offset] != 0xFF: + offset += 1 + continue + + marker = data[offset + 1] + if marker in {0xD8, 0xD9}: # SOI, EOI + offset += 2 + continue + + if offset + 4 > data_len: + break + + length = struct.unpack(">H", data[offset + 2 : offset + 4])[0] + if marker in sof_markers: + if offset + 10 > data_len: + break + precision, h, w, num_comp = struct.unpack( + ">BHHB", data[offset + 4 : offset + 10] + ) + meta = { + "format": "JPEG", + "size": (w, h), + "bit_depth": precision, + "mode": "L" if num_comp == 1 else "RGB", + } + + if num_comp == 3 and offset + 19 <= data_len: + chroma = Exif._parse_jpeg_chroma(data[offset + 10 : offset + 19]) + if chroma: + meta["chroma"] = chroma + return meta + + offset += 2 + length + return {} + + @staticmethod + def _parse_tiff(data: bytes) -> dict: + if not (data.startswith(b"II\x2a\x00") or data.startswith(b"MM\x00\x2a")): + return {} + + endian = "<" if data[:2] == b"II" else ">" + ifd_offset = struct.unpack(f"{endian}I", data[4:8])[0] + if ifd_offset + 2 > len(data): + return {"format": "TIFF"} + + num_entries = struct.unpack(f"{endian}H", data[ifd_offset : ifd_offset + 2])[0] + entry_offset = ifd_offset + 2 + tags = {} + + for _ in range(num_entries): + if entry_offset + 12 > len(data): + break + tag, typ, count, val = struct.unpack( + f"{endian}HHII", data[entry_offset : entry_offset + 12] + ) + entry_offset += 12 + + parsed_val = val + if typ == 3: # SHORT + if count == 1: + val_bytes = struct.pack(f"{endian}I", val) + parsed_val = struct.unpack(f"{endian}H", val_bytes[:2])[0] + elif count > 1 and val + 2 <= len(data): + parsed_val = struct.unpack(f"{endian}H", data[val : val + 2])[0] + + tags[tag] = parsed_val + + meta = {"format": "TIFF"} + if 256 in tags and 257 in tags: + meta["size"] = (tags[256], tags[257]) + if 258 in tags: + meta["bit_depth"] = tags[258] + if 262 in tags: + meta["mode"] = ( + "RGB" + if tags[262] in {2, 6} + else ("L" if tags[262] in {0, 1} else UNKNOWN) + ) + return meta + + @staticmethod + def _parse_webp(data: bytes) -> dict: + if not (data.startswith(b"RIFF") and data[8:12] == b"WEBP"): + return {} + + chunk_type = data[12:16] + if chunk_type == b"VP8 " and len(data) >= 30: + w_raw, h_raw = struct.unpack("= 25: + b0, b1, b2, b3 = data[21:25] + val = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24) + return { + "format": "WEBP", + "size": ((val & 0x3FFF) + 1, ((val >> 14) & 0x3FFF) + 1), + "bit_depth": 8, + "mode": "RGBA" if (val & 0x10000000) else "RGB", + } + if chunk_type == b"VP8X" and len(data) >= 30: + has_alpha = bool(data[20] & 0x10) + w = (data[24] | (data[25] << 8) | (data[26] << 16)) + 1 + h = (data[27] | (data[28] << 8) | (data[29] << 16)) + 1 + return { + "format": "WEBP", + "size": (w, h), + "bit_depth": 8, + "mode": "RGBA" if has_alpha else "RGB", + } + return {"format": "WEBP"} + + @staticmethod + def _parse_heic(data: bytes) -> dict: + if len(data) < 12 or data[4:8] != b"ftyp": + return {} + + brand = data[8:12] + if brand not in {b"heic", b"heix", b"hevc", b"hevx", b"mif1", b"msf1"}: + return {} + + meta = {"format": "HEIC", "bit_depth": 8, "mode": "RGB"} + ispe_idx = data.find(b"ispe") + if ispe_idx != -1 and ispe_idx + 16 <= len(data): + w, h = struct.unpack(">II", data[ispe_idx + 8 : ispe_idx + 16]) + meta["size"] = (w, h) + return meta + + @staticmethod + def parse_raw_header(data: bytes) -> dict: + """Extract metadata directly from raw image header bytes. + + Supports HEIC, JPG, PNG, TIFF, and WEBP. + + Args: + data: Raw header bytes (minimum 64 KB recommended) + + Returns: + dict: Parsed header metadata (format, size, bit_depth, mode, chroma) + """ + if not data: + return {} + + parsers = ( + Exif._parse_png, + Exif._parse_jpeg, + Exif._parse_tiff, + Exif._parse_webp, + Exif._parse_heic, + ) + for parser in parsers: + meta = parser(data) + if meta: + return meta + + return {} diff --git a/tests/data/JPG/meta_no_gps.txt b/tests/data/JPG/meta_no_gps.txt index 3ce9252..ff6f920 100644 --- a/tests/data/JPG/meta_no_gps.txt +++ b/tests/data/JPG/meta_no_gps.txt @@ -4,10 +4,10 @@ Last Modified : 2026-08-31 11:25 Format : JPEG Dimensions : 320 x 240 (0.1 MP) - Bit Depth : unknown + Bit Depth : 8 bits/channel Alpha Channel : No Colorspace : RGB - Chroma Format : unknown + Chroma Format : 4:2:0 [ EXIF Metadata ] GPS Data : Absent diff --git a/tests/test_common.py b/tests/test_common.py index 6e9915b..8ed062b 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -659,6 +659,8 @@ def test_sort_nested_dict(data_nested_dict): "file_ts": "2026-08-31 11:25", "format": "JPEG", "info": { + "bit_depth": 8, + "chroma": "4:2:0", "dpi": ( 72.0, 72.0, diff --git a/tests/test_exif.py b/tests/test_exif.py new file mode 100644 index 0000000..bd305c7 --- /dev/null +++ b/tests/test_exif.py @@ -0,0 +1,112 @@ +"""Test exif.py +pytest -sv tests/test_exif.py +Copyright © 2026 - Present, John Liu +""" + +import struct +import pytest +from PIL import Image +from batch_img.exif import Exif + + +@pytest.fixture +def temp_images(tmp_path): + paths = {} + + # PNG (100x50, RGBA) + png_path = tmp_path / "test.png" + Image.new("RGBA", (100, 50), color="red").save(png_path, format="PNG") + paths["PNG"] = png_path + + # JPEG (80x40, RGB, 4:2:0) + jpg_path = tmp_path / "test.jpg" + Image.new("RGB", (80, 40), color="blue").save( + jpg_path, format="JPEG", subsampling="4:2:0" + ) + paths["JPEG"] = jpg_path + + # TIFF (60x30, RGB) + tiff_path = tmp_path / "test.tiff" + Image.new("RGB", (60, 30), color="green").save(tiff_path, format="TIFF") + paths["TIFF"] = tiff_path + + # WEBP (120x60, RGB) + webp_path = tmp_path / "test.webp" + Image.new("RGB", (120, 60), color="yellow").save(webp_path, format="WEBP") + paths["WEBP"] = webp_path + + # Synthetic HEIC (200x100) + heic_path = tmp_path / "test.heic" + heic_bytes = ( + b"\x00\x00\x00\x14ftypheic\x00\x00\x00\x00heicmif1" + b"\x00\x00\x00\x10ispe\x00\x00\x00\x00" + struct.pack(">II", 200, 100) + ) + heic_path.write_bytes(heic_bytes) + paths["HEIC"] = heic_path + + return paths + + +def test_parse_raw_header_public_api(temp_images): + for fmt, path in temp_images.items(): + data = path.read_bytes() + meta = Exif.parse_raw_header(data) + assert meta["format"] == fmt + + +def test_parse_raw_header_invalid_inputs(): + assert Exif.parse_raw_header(b"") == {} + assert Exif.parse_raw_header(b"UNSUPPORTED_HEADER_BYTES") == {} + + +def test_parse_png(temp_images): + data = temp_images["PNG"].read_bytes() + meta = Exif._parse_png(data) + assert meta == { + "format": "PNG", + "size": (100, 50), + "bit_depth": 8, + "mode": "RGBA", + } + assert Exif._parse_png(b"invalid_png") == {} + + +def test_parse_jpeg(temp_images): + data = temp_images["JPEG"].read_bytes() + meta = Exif._parse_jpeg(data) + assert meta["format"] == "JPEG" + assert meta["size"] == (80, 40) + assert meta["bit_depth"] == 8 + assert meta["mode"] == "RGB" + assert meta.get("chroma") == "4:2:0" + assert Exif._parse_jpeg(b"invalid_jpeg") == {} + + +def test_parse_tiff(temp_images): + data = temp_images["TIFF"].read_bytes() + meta = Exif._parse_tiff(data) + assert meta["format"] == "TIFF" + assert meta["size"] == (60, 30) + assert meta["bit_depth"] == 8 + assert Exif._parse_tiff(b"invalid_tiff") == {} + + +def test_parse_webp(temp_images): + data = temp_images["WEBP"].read_bytes() + meta = Exif._parse_webp(data) + assert meta["format"] == "WEBP" + assert meta["size"] == (120, 60) + assert meta["bit_depth"] == 8 + assert Exif._parse_webp(b"invalid_webp") == {} + + +def test_parse_heic(temp_images): + data = temp_images["HEIC"].read_bytes() + meta = Exif._parse_heic(data) + assert meta == { + "format": "HEIC", + "size": (200, 100), + "bit_depth": 8, + "mode": "RGB", + } + assert Exif._parse_heic(b"invalid_heic") == {} diff --git a/tests/test_info.py b/tests/test_info.py index 0181d01..6fc81be 100644 --- a/tests/test_info.py +++ b/tests/test_info.py @@ -1,6 +1,6 @@ -"""Tests for info.py +"""Test info.py pytest -sv tests/test_info.py -Copyright © 2025 - Present, John Liu +Copyright © 2026 - Present, John Liu """ from os.path import dirname @@ -279,10 +279,10 @@ def test_read_exif_quiet_mode_write_error(self, mock_read, tmp_path, monkeypatch "last_modified": "2026-08-31 11:25", "format": "JPEG", "dimensions": "320 x 240 (0.1 MP)", - "bit_depth": UNKNOWN, + "bit_depth": "8 bits/channel", "alpha_channel": "No", "colorspace": "RGB", - "chroma_format": UNKNOWN, + "chroma_format": "4:2:0", }, }, Path(f"{_dir}/data/JPG/meta_no_gps.txt"), From c9b718e2299fa4a1f6e2d4a3c1bb68bdf1652ed4 Mon Sep 17 00:00:00 2001 From: John Liu Date: Mon, 31 Aug 2026 13:07:28 -0700 Subject: [PATCH 4/5] Minor --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 99a444d..df03a97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,7 @@ lint.ignore = [ "RET504", "PLR2004", # Magic value used in comparison, consider replacing `3` with a constant variable "PLW0717", # too-many-statements-in-try-clause - "PLR0912", + "PLR0912", # too-many-branches ] lint.fixable = ["E", "F", "I", "UP"] exclude = [ From 6ac15c3d08e560af780c3a42bc0286392690c19b Mon Sep 17 00:00:00 2001 From: John Liu Date: Mon, 31 Aug 2026 13:11:53 -0700 Subject: [PATCH 5/5] Minor --- batch_img/exif.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/batch_img/exif.py b/batch_img/exif.py index e202d0b..093b262 100644 --- a/batch_img/exif.py +++ b/batch_img/exif.py @@ -210,13 +210,12 @@ def parse_raw_header(data: bytes) -> dict: """ if not data: return {} - parsers = ( - Exif._parse_png, + Exif._parse_heic, Exif._parse_jpeg, + Exif._parse_png, Exif._parse_tiff, Exif._parse_webp, - Exif._parse_heic, ) for parser in parsers: meta = parser(data)