From 05e8d747886c02c74e3a4590931fa684e9b59214 Mon Sep 17 00:00:00 2001 From: scher Date: Sun, 6 Sep 2026 21:48:15 +0300 Subject: [PATCH 01/13] test: add conversion test harness --- pyproject.toml | 4 ++++ tests/test_cli.py | 13 +++++++++++++ tests/test_convert.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 tests/test_cli.py create mode 100644 tests/test_convert.py diff --git a/pyproject.toml b/pyproject.toml index 0517674..037ebf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,3 +12,7 @@ imc = "cli.app:main" [build-system] requires = ["setuptools>=68.0"] build-backend = "setuptools.build_meta" + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..492bbc4 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,13 @@ +import subprocess +import sys +from pathlib import Path + +def run_cli(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "main.py", *args], + cwd=cwd, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) diff --git a/tests/test_convert.py b/tests/test_convert.py new file mode 100644 index 0000000..1dd2cc6 --- /dev/null +++ b/tests/test_convert.py @@ -0,0 +1,42 @@ +from pathlib import Path + +import pytest +from PIL import Image + +from cli.convert import ConvertOptions, convert_paths + +def make_image(path: Path, mode: str = "RGB") -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + if mode == "RGBA": + image = Image.new(mode, (2, 2), (255, 0, 0, 128)) + else: + image = Image.new(mode, (2, 2), (255, 0, 0)) + image.save(path) + return path + +def options( + *, + target_extension: str = ".png", + output: Path | None = None, + recursive: bool = False, + overwrite: bool = False, + remove_source: bool = False, + quality: int | None = None, +) -> ConvertOptions: + return ConvertOptions( + target_extension=target_extension, + output=output, + recursive=recursive, + overwrite=overwrite, + remove_source=remove_source, + quality=quality, + ) + +def test_single_file_converts_next_to_source(tmp_path: Path): + source = make_image(tmp_path / "image.webp") + + converted = convert_paths([source], options(target_extension=".png")) + + assert converted == [tmp_path / "image.png"] + assert (tmp_path / "image.png").is_file() + assert source.is_file() From b52212d36efb6943d99ecaddc9314a4f6d2d6120 Mon Sep 17 00:00:00 2001 From: scher Date: Sun, 6 Sep 2026 21:55:01 +0300 Subject: [PATCH 02/13] "black" fmt --- cli/app.py | 22 ++++++++++++++++++---- cli/convert.py | 10 +++++++++- cli/utils.py | 6 ++++++ pyproject.toml | 7 +++++++ tests/test_cli.py | 1 + tests/test_convert.py | 3 +++ 6 files changed, 44 insertions(+), 5 deletions(-) diff --git a/cli/app.py b/cli/app.py index 243cfc1..7dd02a9 100644 --- a/cli/app.py +++ b/cli/app.py @@ -7,6 +7,7 @@ from cli.utils import available_extensions, normalize_extension from importlib.metadata import version, PackageNotFoundError + def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="converter", @@ -14,12 +15,20 @@ def _build_parser() -> argparse.ArgumentParser: ) subparsers = parser.add_subparsers(dest="command", required=True) - convert_parser = subparsers.add_parser("convert", help="Convert files or directories") + convert_parser = subparsers.add_parser( + "convert", help="Convert files or directories" + ) convert_parser.add_argument("input", nargs="+", help="File or directory path(s)") - convert_parser.add_argument("--to", "-t", required=True, help="Target extension (png, jpg, webp, ...)") + convert_parser.add_argument( + "--to", "-t", required=True, help="Target extension (png, jpg, webp, ...)" + ) convert_parser.add_argument("--output", "-o", help="Output directory or file path") - convert_parser.add_argument("--recursive", "-r", action="store_true", help="Convert directories recursively") - convert_parser.add_argument("--overwrite", action="store_true", help="Overwrite existing files") + convert_parser.add_argument( + "--recursive", "-r", action="store_true", help="Convert directories recursively" + ) + convert_parser.add_argument( + "--overwrite", action="store_true", help="Overwrite existing files" + ) convert_parser.add_argument( "--remove-source", action="store_true", @@ -33,12 +42,14 @@ def _build_parser() -> argparse.ArgumentParser: return parser + def _handle_list_formats() -> int: extensions = sorted(available_extensions().keys()) print("Supported extensions:") print(" ".join(extensions)) return 0 + def _handle_convert(args: argparse.Namespace) -> int: target_extension = normalize_extension(args.to) output = Path(args.output).expanduser() if args.output else None @@ -60,16 +71,19 @@ def _handle_convert(args: argparse.Namespace) -> int: convert_paths(inputs, options) return 0 + def get_version() -> str: try: return version("imc-image-converter") except PackageNotFoundError: return "0.0.0" + def _handle_version() -> int: print(get_version()) return 0 + def main() -> None: parser = _build_parser() args = parser.parse_args() diff --git a/cli/convert.py b/cli/convert.py index afdda91..eaf858c 100644 --- a/cli/convert.py +++ b/cli/convert.py @@ -13,6 +13,7 @@ prepare_image_for_format, ) + @dataclass(frozen=True) class ConvertOptions: target_extension: str @@ -22,6 +23,7 @@ class ConvertOptions: remove_source: bool quality: int | None + def _target_path(source: Path, base_dir: Path, options: ConvertOptions) -> Path: target_name = source.stem + options.target_extension if options.output is None: @@ -33,7 +35,10 @@ def _target_path(source: Path, base_dir: Path, options: ConvertOptions) -> Path: return options.output / relative.with_suffix(options.target_extension) return options.output / target_name -def _save_image(img: Image.Image, target: Path, target_format: str, quality: int | None) -> None: + +def _save_image( + img: Image.Image, target: Path, target_format: str, quality: int | None +) -> None: target.parent.mkdir(parents=True, exist_ok=True) save_kwargs = {} if quality is not None and target_format.upper() in {"JPEG", "JPG", "WEBP", "AVIF"}: @@ -42,6 +47,7 @@ def _save_image(img: Image.Image, target: Path, target_format: str, quality: int save_kwargs["optimize"] = True img.save(target, format=target_format, **save_kwargs) + def _convert_one(source: Path, base_dir: Path, options: ConvertOptions) -> Path: target_format = format_for_extension(options.target_extension) target = _target_path(source, base_dir, options) @@ -57,6 +63,7 @@ def _convert_one(source: Path, base_dir: Path, options: ConvertOptions) -> Path: return target + def convert_paths(paths: Iterable[Path], options: ConvertOptions) -> list[Path]: converted = [] for path in paths: @@ -65,6 +72,7 @@ def convert_paths(paths: Iterable[Path], options: ConvertOptions) -> list[Path]: converted.append(_convert_one(source, base_dir, options)) return converted + def parse_quality(value: str | None) -> int | None: if value is None: return None diff --git a/cli/utils.py b/cli/utils.py index 1fc2112..c91ab38 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -5,6 +5,7 @@ from PIL import Image + def normalize_extension(ext: str) -> str: ext = ext.strip().lower() if not ext: @@ -13,21 +14,25 @@ def normalize_extension(ext: str) -> str: ext = "." + ext return ext + def available_extensions() -> dict[str, str]: return {ext.lower(): fmt for ext, fmt in Image.registered_extensions().items()} + def format_for_extension(ext: str) -> str: extensions = available_extensions() if ext not in extensions: raise ValueError(f"Unsupported extension: {ext}") return extensions[ext] + def is_image_path(path: Path) -> bool: if not path.is_file(): return False extensions = available_extensions() return path.suffix.lower() in extensions + def collect_images(root: Path, recursive: bool) -> Iterable[Path]: if root.is_file(): return [root] @@ -37,6 +42,7 @@ def collect_images(root: Path, recursive: bool) -> Iterable[Path]: return [p for p in root.rglob("*") if is_image_path(p)] return [p for p in root.iterdir() if is_image_path(p)] + def prepare_image_for_format(img: Image.Image, target_format: str) -> Image.Image: target_format = target_format.upper() if target_format in {"JPEG", "JPG"}: diff --git a/pyproject.toml b/pyproject.toml index 037ebf8..19eb497 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,9 @@ readme = "README.md" requires-python = ">=3.9" dependencies = ["Pillow>=10.0.0"] +[project.optional-dependencies] +dev = ["black==26.5.1", "pytest"] + [project.scripts] imc = "cli.app:main" @@ -16,3 +19,7 @@ build-backend = "setuptools.build_meta" [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["."] + +[tool.black] +line-length = 88 +target-version = ["py39"] diff --git a/tests/test_cli.py b/tests/test_cli.py index 492bbc4..25cc6be 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,6 +2,7 @@ import sys from pathlib import Path + def run_cli(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]: return subprocess.run( [sys.executable, "main.py", *args], diff --git a/tests/test_convert.py b/tests/test_convert.py index 1dd2cc6..b2596bd 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -5,6 +5,7 @@ from cli.convert import ConvertOptions, convert_paths + def make_image(path: Path, mode: str = "RGB") -> Path: path.parent.mkdir(parents=True, exist_ok=True) if mode == "RGBA": @@ -14,6 +15,7 @@ def make_image(path: Path, mode: str = "RGB") -> Path: image.save(path) return path + def options( *, target_extension: str = ".png", @@ -32,6 +34,7 @@ def options( quality=quality, ) + def test_single_file_converts_next_to_source(tmp_path: Path): source = make_image(tmp_path / "image.webp") From e5c4647f4684587f2fc91ce5e0e662c74583f90b Mon Sep 17 00:00:00 2001 From: scher Date: Sun, 6 Sep 2026 21:59:01 +0300 Subject: [PATCH 03/13] feat: preflight conversion plans --- cli/convert.py | 57 +++++++++++++++++++++++++++++++------------ tests/test_convert.py | 13 ++++++++++ 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/cli/convert.py b/cli/convert.py index eaf858c..8594b9e 100644 --- a/cli/convert.py +++ b/cli/convert.py @@ -24,6 +24,13 @@ class ConvertOptions: quality: int | None +@dataclass(frozen=True) +class Conversion: + source: Path + target: Path + target_format: str + + def _target_path(source: Path, base_dir: Path, options: ConvertOptions) -> Path: target_name = source.stem + options.target_extension if options.output is None: @@ -48,29 +55,49 @@ def _save_image( img.save(target, format=target_format, **save_kwargs) -def _convert_one(source: Path, base_dir: Path, options: ConvertOptions) -> Path: +def plan_conversions( + paths: Iterable[Path], options: ConvertOptions +) -> list[Conversion]: + conversions: list[Conversion] = [] target_format = format_for_extension(options.target_extension) - target = _target_path(source, base_dir, options) - if target.exists() and not options.overwrite: - raise FileExistsError(f"Target already exists: {target}") + for path in paths: + base_dir = path if path.is_dir() else path.parent + for source in collect_images(path, options.recursive): + conversions.append( + Conversion( + source=source, + target=_target_path(source, base_dir, options), + target_format=target_format, + ) + ) + return conversions + + +def validate_conversions( + conversions: list[Conversion], options: ConvertOptions +) -> None: + for conversion in conversions: + if conversion.target.exists() and not options.overwrite: + raise FileExistsError(f"Target already exists: {conversion.target}") + - with Image.open(source) as img: - prepared = prepare_image_for_format(img, target_format) - _save_image(prepared, target, target_format, options.quality) +def _convert_one(conversion: Conversion, options: ConvertOptions) -> Path: + with Image.open(conversion.source) as img: + prepared = prepare_image_for_format(img, conversion.target_format) + _save_image( + prepared, conversion.target, conversion.target_format, options.quality + ) if options.remove_source: - source.unlink() + conversion.source.unlink() - return target + return conversion.target def convert_paths(paths: Iterable[Path], options: ConvertOptions) -> list[Path]: - converted = [] - for path in paths: - base_dir = path if path.is_dir() else path.parent - for source in collect_images(path, options.recursive): - converted.append(_convert_one(source, base_dir, options)) - return converted + conversions = plan_conversions(paths, options) + validate_conversions(conversions, options) + return [_convert_one(conversion, options) for conversion in conversions] def parse_quality(value: str | None) -> int | None: diff --git a/tests/test_convert.py b/tests/test_convert.py index b2596bd..1f50f3c 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -43,3 +43,16 @@ def test_single_file_converts_next_to_source(tmp_path: Path): assert converted == [tmp_path / "image.png"] assert (tmp_path / "image.png").is_file() assert source.is_file() + + +def test_existing_target_preflight_does_not_partially_convert(tmp_path: Path): + first = make_image(tmp_path / "first.webp") + second = make_image(tmp_path / "second.webp") + make_image(tmp_path / "second.png") + + with pytest.raises(FileExistsError, match="Target already exists"): + convert_paths([first, second], options(target_extension=".png")) + + assert not (tmp_path / "first.png").exists() + assert first.exists() + assert second.exists() From e91cc88f46b8d7c5c6c64e275e5f3b1b93b0a402 Mon Sep 17 00:00:00 2001 From: scher Date: Sun, 6 Sep 2026 22:01:28 +0300 Subject: [PATCH 04/13] fix: reject same path conversions --- cli/convert.py | 4 ++++ tests/test_convert.py | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/cli/convert.py b/cli/convert.py index 8594b9e..58ce76d 100644 --- a/cli/convert.py +++ b/cli/convert.py @@ -77,6 +77,10 @@ def validate_conversions( conversions: list[Conversion], options: ConvertOptions ) -> None: for conversion in conversions: + if conversion.source.resolve() == conversion.target.resolve(): + raise ValueError( + f"Source and target are the same file: {conversion.source}" + ) if conversion.target.exists() and not options.overwrite: raise FileExistsError(f"Target already exists: {conversion.target}") diff --git a/tests/test_convert.py b/tests/test_convert.py index 1f50f3c..c4059f4 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -56,3 +56,15 @@ def test_existing_target_preflight_does_not_partially_convert(tmp_path: Path): assert not (tmp_path / "first.png").exists() assert first.exists() assert second.exists() + + +def test_remove_source_never_deletes_same_target_path(tmp_path: Path): + source = make_image(tmp_path / "image.png") + + with pytest.raises(ValueError, match="Source and target are the same file"): + convert_paths( + [source], + options(target_extension=".png", overwrite=True, remove_source=True), + ) + + assert source.exists() From 5151ebc7a5b08d5e57f65b51380245bcdaf4fd97 Mon Sep 17 00:00:00 2001 From: scher Date: Sun, 6 Sep 2026 22:04:40 +0300 Subject: [PATCH 05/13] fix: reject duplicate conversion targets --- cli/convert.py | 11 +++++++++++ tests/test_convert.py | 10 ++++++++++ 2 files changed, 21 insertions(+) diff --git a/cli/convert.py b/cli/convert.py index 58ce76d..8971c6b 100644 --- a/cli/convert.py +++ b/cli/convert.py @@ -76,11 +76,22 @@ def plan_conversions( def validate_conversions( conversions: list[Conversion], options: ConvertOptions ) -> None: + seen_targets: dict[Path, Path] = {} for conversion in conversions: if conversion.source.resolve() == conversion.target.resolve(): raise ValueError( f"Source and target are the same file: {conversion.source}" ) + + resolved_target = conversion.target.resolve() + existing_source = seen_targets.get(resolved_target) + if existing_source is not None: + raise ValueError( + f"Multiple inputs target the same output: {conversion.target}" + ) + seen_targets[resolved_target] = conversion.source + + for conversion in conversions: if conversion.target.exists() and not options.overwrite: raise FileExistsError(f"Target already exists: {conversion.target}") diff --git a/tests/test_convert.py b/tests/test_convert.py index c4059f4..7468c62 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -68,3 +68,13 @@ def test_remove_source_never_deletes_same_target_path(tmp_path: Path): ) assert source.exists() + + +def test_duplicate_targets_are_rejected_before_conversion(tmp_path: Path): + jpg = make_image(tmp_path / "photo.jpg") + png = make_image(tmp_path / "photo.png") + + with pytest.raises(ValueError, match="Multiple inputs target the same output"): + convert_paths([jpg, png], options(target_extension=".webp", overwrite=True)) + + assert not (tmp_path / "photo.webp").exists() From aa0904bf97d509b3a7886a3ba0122fb020c1e839 Mon Sep 17 00:00:00 2001 From: scher Date: Sun, 6 Sep 2026 22:10:27 +0300 Subject: [PATCH 06/13] fix: skip recursive output directories --- cli/convert.py | 16 +++++++++++++++- cli/utils.py | 29 ++++++++++++++++++++++++++--- tests/test_convert.py | 17 +++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/cli/convert.py b/cli/convert.py index 8971c6b..d1640c1 100644 --- a/cli/convert.py +++ b/cli/convert.py @@ -43,6 +43,18 @@ def _target_path(source: Path, base_dir: Path, options: ConvertOptions) -> Path: return options.output / target_name +def _excluded_output_dirs(path: Path, options: ConvertOptions) -> list[Path]: + if options.output is None or not options.recursive or not path.is_dir(): + return [] + if options.output.suffix: + return [] + try: + options.output.resolve().relative_to(path.resolve()) + except ValueError: + return [] + return [options.output] + + def _save_image( img: Image.Image, target: Path, target_format: str, quality: int | None ) -> None: @@ -62,7 +74,9 @@ def plan_conversions( target_format = format_for_extension(options.target_extension) for path in paths: base_dir = path if path.is_dir() else path.parent - for source in collect_images(path, options.recursive): + for source in collect_images( + path, options.recursive, _excluded_output_dirs(path, options) + ): conversions.append( Conversion( source=source, diff --git a/cli/utils.py b/cli/utils.py index c91ab38..7d0ff85 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -33,13 +33,36 @@ def is_image_path(path: Path) -> bool: return path.suffix.lower() in extensions -def collect_images(root: Path, recursive: bool) -> Iterable[Path]: +def _is_relative_to(path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + return True + except ValueError: + return False + + +def collect_images( + root: Path, + recursive: bool, + exclude_dirs: Iterable[Path] = (), +) -> Iterable[Path]: + excluded = [directory.resolve() for directory in exclude_dirs] if root.is_file(): - return [root] + return [root] if is_image_path(root) else [] if not root.is_dir(): return [] if recursive: - return [p for p in root.rglob("*") if is_image_path(p)] + images = [] + for path in root.rglob("*"): + resolved_parent = path.parent.resolve() + if any( + _is_relative_to(resolved_parent, excluded_dir) + for excluded_dir in excluded + ): + continue + if is_image_path(path): + images.append(path) + return images return [p for p in root.iterdir() if is_image_path(p)] diff --git a/tests/test_convert.py b/tests/test_convert.py index 7468c62..3c17696 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -78,3 +78,20 @@ def test_duplicate_targets_are_rejected_before_conversion(tmp_path: Path): convert_paths([jpg, png], options(target_extension=".webp", overwrite=True)) assert not (tmp_path / "photo.webp").exists() + + +def test_recursive_conversion_skips_existing_output_tree(tmp_path: Path): + root = tmp_path / "photos" + source = make_image(root / "a.png") + existing_output = make_image(root / "converted" / "old.png") + + converted = convert_paths( + [root], + options(target_extension=".webp", output=root / "converted", recursive=True), + ) + + assert converted == [root / "converted" / "a.webp"] + assert (root / "converted" / "a.webp").exists() + assert existing_output.exists() + assert not (root / "converted" / "converted" / "old.webp").exists() + assert source.exists() From 130b3f272abf215d83a488b0e0100a345993f186 Mon Sep 17 00:00:00 2001 From: scher Date: Sun, 6 Sep 2026 22:13:55 +0300 Subject: [PATCH 07/13] test: validate explicit image inputs --- tests/test_convert.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_convert.py b/tests/test_convert.py index 3c17696..3bf5355 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -95,3 +95,13 @@ def test_recursive_conversion_skips_existing_output_tree(tmp_path: Path): assert existing_output.exists() assert not (root / "converted" / "converted" / "old.webp").exists() assert source.exists() + + +def test_explicit_non_image_file_is_not_converted(tmp_path: Path): + source = tmp_path / "README.md" + source.write_text("# not an image\n", encoding="utf-8") + + converted = convert_paths([source], options(target_extension=".png")) + + assert converted == [] + assert not (tmp_path / "README.png").exists() From d2c9db42ab60de616da8e76eaa21c106870bd9ba Mon Sep 17 00:00:00 2001 From: scher Date: Sun, 6 Sep 2026 22:18:33 +0300 Subject: [PATCH 08/13] report cli errors without tracebacks --- cli/app.py | 18 +++++++++++++++--- tests/test_cli.py | 22 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/cli/app.py b/cli/app.py index 7dd02a9..7abea9e 100644 --- a/cli/app.py +++ b/cli/app.py @@ -8,6 +8,13 @@ from importlib.metadata import version, PackageNotFoundError +def parse_quality_arg(value: str) -> int: + try: + return parse_quality(value) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"invalid quality: {exc}") from exc + + def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="converter", @@ -34,7 +41,9 @@ def _build_parser() -> argparse.ArgumentParser: action="store_true", help="Remove source files after successful conversion", ) - convert_parser.add_argument("--quality", help="Quality for JPEG/WEBP/AVIF (1-100)") + convert_parser.add_argument( + "--quality", type=parse_quality_arg, help="Quality for JPEG/WEBP/AVIF (1-100)" + ) subparsers.add_parser("list-formats", help="List supported extensions") @@ -53,7 +62,7 @@ def _handle_list_formats() -> int: def _handle_convert(args: argparse.Namespace) -> int: target_extension = normalize_extension(args.to) output = Path(args.output).expanduser() if args.output else None - quality = parse_quality(args.quality) + quality = args.quality inputs = [Path(value).expanduser() for value in args.input] if output is not None and len(inputs) > 1 and output.suffix: @@ -91,7 +100,10 @@ def main() -> None: if args.command == "list-formats": raise SystemExit(_handle_list_formats()) if args.command == "convert": - raise SystemExit(_handle_convert(args)) + try: + raise SystemExit(_handle_convert(args)) + except (ValueError, FileExistsError) as exc: + parser.error(str(exc)) if args.command == "version": raise SystemExit(_handle_version()) raise SystemExit(1) diff --git a/tests/test_cli.py b/tests/test_cli.py index 25cc6be..ae69b8e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,3 +12,25 @@ def run_cli(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]: stderr=subprocess.PIPE, check=False, ) + + +def test_invalid_quality_reports_argparse_error(tmp_path: Path): + repo = Path(__file__).resolve().parents[1] + + result = run_cli( + ["convert", "missing.png", "--to", "jpg", "--quality", "potato"], repo + ) + + assert result.returncode != 0 + assert "invalid quality" in result.stderr.lower() + assert "Traceback" not in result.stderr + + +def test_unsupported_target_reports_cli_error(tmp_path: Path): + repo = Path(__file__).resolve().parents[1] + + result = run_cli(["convert", "missing.png", "--to", "definitely-not-real"], repo) + + assert result.returncode != 0 + assert "unsupported extension" in result.stderr.lower() + assert "Traceback" not in result.stderr From 0e72316372b12e9354acc45794a61f566ca2df55 Mon Sep 17 00:00:00 2001 From: scher Date: Sun, 6 Sep 2026 22:21:35 +0300 Subject: [PATCH 09/13] separate readable and writable formats --- cli/app.py | 11 +++++++---- cli/utils.py | 16 ++++++++++++++-- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/cli/app.py b/cli/app.py index 7abea9e..61d2059 100644 --- a/cli/app.py +++ b/cli/app.py @@ -4,7 +4,7 @@ from pathlib import Path from cli.convert import ConvertOptions, convert_paths, parse_quality -from cli.utils import available_extensions, normalize_extension +from cli.utils import normalize_extension, readable_extensions, writable_extensions from importlib.metadata import version, PackageNotFoundError @@ -53,9 +53,12 @@ def _build_parser() -> argparse.ArgumentParser: def _handle_list_formats() -> int: - extensions = sorted(available_extensions().keys()) - print("Supported extensions:") - print(" ".join(extensions)) + readable = sorted(readable_extensions().keys()) + writable = sorted(writable_extensions().keys()) + print("Readable:") + print(" ".join(readable)) + print("Writable:") + print(" ".join(writable)) return 0 diff --git a/cli/utils.py b/cli/utils.py index 7d0ff85..69df2c3 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -15,12 +15,24 @@ def normalize_extension(ext: str) -> str: return ext -def available_extensions() -> dict[str, str]: +def readable_extensions() -> dict[str, str]: return {ext.lower(): fmt for ext, fmt in Image.registered_extensions().items()} +def writable_extensions() -> dict[str, str]: + return { + ext.lower(): fmt + for ext, fmt in Image.registered_extensions().items() + if fmt.upper() in Image.SAVE + } + + +def available_extensions() -> dict[str, str]: + return readable_extensions() + + def format_for_extension(ext: str) -> str: - extensions = available_extensions() + extensions = writable_extensions() if ext not in extensions: raise ValueError(f"Unsupported extension: {ext}") return extensions[ext] From 1dd5312f51183d8a245e91911a664588171a94fc Mon Sep 17 00:00:00 2001 From: scher Date: Sun, 6 Sep 2026 22:26:46 +0300 Subject: [PATCH 10/13] clarify output path semantics --- README.md | 8 ++++---- cli/app.py | 15 +++++++++++---- cli/convert.py | 5 +++-- tests/test_cli.py | 24 ++++++++++++++++++++++++ tests/test_convert.py | 16 ++++++++++++++++ 5 files changed, 58 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 30c29e2..25f9347 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,10 @@ python main.py convert image.png --to webp --remove-source ## Output Rules - If `--output` is omitted, converted files are saved next to their sources. -- If `--output` is a directory, outputs go inside it. -- If converting a directory with `--recursive`, the folder structure is preserved inside the output directory. -- If multiple inputs are provided, `--output` must be a directory. -- If a folder input is provided, `--output` must be a directory. +- For multiple inputs or any folder input, `--output` is always treated as an output directory. +- For one explicit file input, an existing `--output` directory receives the converted file. +- For one explicit file input, a non-existing `--output` path with a suffix is treated as the output file path. +- Recursive folder conversion preserves paths relative to the input folder inside the output directory. ## Usage Examples Convert a single file to PNG: diff --git a/cli/app.py b/cli/app.py index 61d2059..c83db69 100644 --- a/cli/app.py +++ b/cli/app.py @@ -62,16 +62,22 @@ def _handle_list_formats() -> int: return 0 +def _output_is_file(inputs: list[Path], output: Path | None) -> bool: + if output is None: + return False + if len(inputs) != 1 or inputs[0].is_dir(): + return False + if output.exists(): + return output.is_file() + return bool(output.suffix) + + def _handle_convert(args: argparse.Namespace) -> int: target_extension = normalize_extension(args.to) output = Path(args.output).expanduser() if args.output else None quality = args.quality inputs = [Path(value).expanduser() for value in args.input] - if output is not None and len(inputs) > 1 and output.suffix: - raise SystemExit("--output must be a directory when converting multiple inputs") - if output is not None and output.suffix and any(path.is_dir() for path in inputs): - raise SystemExit("--output must be a directory when converting a folder") options = ConvertOptions( target_extension=target_extension, output=output, @@ -79,6 +85,7 @@ def _handle_convert(args: argparse.Namespace) -> int: overwrite=args.overwrite, remove_source=args.remove_source, quality=quality, + output_is_file=_output_is_file(inputs, output), ) convert_paths(inputs, options) return 0 diff --git a/cli/convert.py b/cli/convert.py index d1640c1..1c61d5f 100644 --- a/cli/convert.py +++ b/cli/convert.py @@ -22,6 +22,7 @@ class ConvertOptions: overwrite: bool remove_source: bool quality: int | None + output_is_file: bool = False @dataclass(frozen=True) @@ -35,7 +36,7 @@ def _target_path(source: Path, base_dir: Path, options: ConvertOptions) -> Path: target_name = source.stem + options.target_extension if options.output is None: return source.with_name(target_name) - if options.output.is_file() or options.output.suffix: + if options.output_is_file: return options.output if options.recursive and base_dir.is_dir(): relative = source.relative_to(base_dir) @@ -46,7 +47,7 @@ def _target_path(source: Path, base_dir: Path, options: ConvertOptions) -> Path: def _excluded_output_dirs(path: Path, options: ConvertOptions) -> list[Path]: if options.output is None or not options.recursive or not path.is_dir(): return [] - if options.output.suffix: + if options.output_is_file: return [] try: options.output.resolve().relative_to(path.resolve()) diff --git a/tests/test_cli.py b/tests/test_cli.py index ae69b8e..de3f406 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,6 +2,8 @@ import sys from pathlib import Path +from PIL import Image + def run_cli(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]: return subprocess.run( @@ -14,6 +16,13 @@ def run_cli(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]: ) +def make_image(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + image = Image.new("RGB", (2, 2), (255, 0, 0)) + image.save(path) + return path + + def test_invalid_quality_reports_argparse_error(tmp_path: Path): repo = Path(__file__).resolve().parents[1] @@ -34,3 +43,18 @@ def test_unsupported_target_reports_cli_error(tmp_path: Path): assert result.returncode != 0 assert "unsupported extension" in result.stderr.lower() assert "Traceback" not in result.stderr + + +def test_dotted_output_directory_is_allowed_for_folder_cli(tmp_path: Path): + repo = Path(__file__).resolve().parents[1] + source_dir = tmp_path / "photos" + output_dir = tmp_path / "archive.images" + make_image(source_dir / "a.webp") + + result = run_cli( + ["convert", str(source_dir), "--to", "png", "--output", str(output_dir)], + repo, + ) + + assert result.returncode == 0 + assert (output_dir / "a.png").exists() diff --git a/tests/test_convert.py b/tests/test_convert.py index 3bf5355..69f6582 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -24,6 +24,7 @@ def options( overwrite: bool = False, remove_source: bool = False, quality: int | None = None, + output_is_file: bool = False, ) -> ConvertOptions: return ConvertOptions( target_extension=target_extension, @@ -32,6 +33,7 @@ def options( overwrite=overwrite, remove_source=remove_source, quality=quality, + output_is_file=output_is_file, ) @@ -105,3 +107,17 @@ def test_explicit_non_image_file_is_not_converted(tmp_path: Path): assert converted == [] assert not (tmp_path / "README.png").exists() + + +def test_dotted_output_directory_is_allowed_for_folder_input(tmp_path: Path): + source_dir = tmp_path / "photos" + output_dir = tmp_path / "archive.images" + make_image(source_dir / "a.webp") + + converted = convert_paths( + [source_dir], + options(target_extension=".png", output=output_dir, recursive=False), + ) + + assert converted == [output_dir / "a.png"] + assert (output_dir / "a.png").exists() From 60f8c3bf8fa5caf652bd16e3a04fb6b44e2d6dd0 Mon Sep 17 00:00:00 2001 From: scher Date: Sun, 6 Sep 2026 22:34:22 +0300 Subject: [PATCH 11/13] preserve recursive input roots --- README.md | 1 + cli/convert.py | 24 +++++++++++++++++++++--- tests/test_convert.py | 20 ++++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 25f9347..0b2d970 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ python main.py convert image.png --to webp --remove-source - For one explicit file input, an existing `--output` directory receives the converted file. - For one explicit file input, a non-existing `--output` path with a suffix is treated as the output file path. - Recursive folder conversion preserves paths relative to the input folder inside the output directory. +- When recursively converting multiple folder inputs to one output directory, each input folder name is preserved under the output directory. ## Usage Examples Convert a single file to PNG: diff --git a/cli/convert.py b/cli/convert.py index 1c61d5f..9e57f7d 100644 --- a/cli/convert.py +++ b/cli/convert.py @@ -32,7 +32,12 @@ class Conversion: target_format: str -def _target_path(source: Path, base_dir: Path, options: ConvertOptions) -> Path: +def _target_path( + source: Path, + base_dir: Path, + options: ConvertOptions, + preserve_root_name: bool = False, +) -> Path: target_name = source.stem + options.target_extension if options.output is None: return source.with_name(target_name) @@ -40,6 +45,8 @@ def _target_path(source: Path, base_dir: Path, options: ConvertOptions) -> Path: return options.output if options.recursive and base_dir.is_dir(): relative = source.relative_to(base_dir) + if preserve_root_name: + relative = Path(base_dir.name) / relative return options.output / relative.with_suffix(options.target_extension) return options.output / target_name @@ -71,9 +78,13 @@ def _save_image( def plan_conversions( paths: Iterable[Path], options: ConvertOptions ) -> list[Conversion]: + input_paths = list(paths) + recursive_directory_count = sum( + 1 for path in input_paths if path.is_dir() and options.recursive + ) conversions: list[Conversion] = [] target_format = format_for_extension(options.target_extension) - for path in paths: + for path in input_paths: base_dir = path if path.is_dir() else path.parent for source in collect_images( path, options.recursive, _excluded_output_dirs(path, options) @@ -81,7 +92,14 @@ def plan_conversions( conversions.append( Conversion( source=source, - target=_target_path(source, base_dir, options), + target=_target_path( + source, + base_dir, + options, + preserve_root_name=( + recursive_directory_count > 1 and path.is_dir() + ), + ), target_format=target_format, ) ) diff --git a/tests/test_convert.py b/tests/test_convert.py index 69f6582..253eca9 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -121,3 +121,23 @@ def test_dotted_output_directory_is_allowed_for_folder_input(tmp_path: Path): assert converted == [output_dir / "a.png"] assert (output_dir / "a.png").exists() + + +def test_recursive_multiple_directories_preserve_root_names(tmp_path: Path): + cats = tmp_path / "cats" + dogs = tmp_path / "dogs" + output = tmp_path / "out" + make_image(cats / "x" / "a.png") + make_image(dogs / "x" / "a.png") + + converted = convert_paths( + [cats, dogs], + options(target_extension=".jpg", output=output, recursive=True), + ) + + assert converted == [ + output / "cats" / "x" / "a.jpg", + output / "dogs" / "x" / "a.jpg", + ] + assert (output / "cats" / "x" / "a.jpg").exists() + assert (output / "dogs" / "x" / "a.jpg").exists() From a236ddc42677048b6b961d4ddea774321c86f098 Mon Sep 17 00:00:00 2001 From: scher Date: Sun, 6 Sep 2026 22:36:37 +0300 Subject: [PATCH 12/13] docs: define metadata and jpeg alpha behavior --- README.md | 4 ++++ tests/test_convert.py | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/README.md b/README.md index 0b2d970..adc41ad 100644 --- a/README.md +++ b/README.md @@ -65,3 +65,7 @@ Set quality for JPEG/WEBP/AVIF: ```bash python main.py convert image.png --to jpg --quality 85 ``` + +## Notes +- Metadata such as EXIF, ICC profiles, orientation tags and DPI is not preserved. +- When converting transparent images to JPEG, transparency is discarded by converting the image to RGB. diff --git a/tests/test_convert.py b/tests/test_convert.py index 253eca9..c95cbd5 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -141,3 +141,13 @@ def test_recursive_multiple_directories_preserve_root_names(tmp_path: Path): ] assert (output / "cats" / "x" / "a.jpg").exists() assert (output / "dogs" / "x" / "a.jpg").exists() + + +def test_rgba_image_converts_to_jpeg_rgb(tmp_path: Path): + source = make_image(tmp_path / "alpha.png", mode="RGBA") + + converted = convert_paths([source], options(target_extension=".jpg")) + + assert converted == [tmp_path / "alpha.jpg"] + with Image.open(tmp_path / "alpha.jpg") as image: + assert image.mode == "RGB" From d82f1982aa44a3eeb532120c9649246da073b462 Mon Sep 17 00:00:00 2001 From: scher Date: Sun, 6 Sep 2026 22:41:42 +0300 Subject: [PATCH 13/13] docs: refresh package usage and add ci --- .github/workflows/ci.yml | 36 ++++++++++++++++++++++++++++++++++++ README.md | 33 ++++++++++++++++++++++----------- pyproject.toml | 6 +++--- 3 files changed, 61 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ec8be3a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: ci + +on: + push: + pull_request: + +jobs: + checks: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.x"] + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Upgrade pip + run: python -m pip install --upgrade pip + + - name: Install package and development tools + run: python -m pip install -e ".[dev]" + + - name: Check formatting + run: python -m black --check main.py cli tests + + - name: Run tests + run: python -m pytest -q + + - name: Check version command + run: python main.py version diff --git a/README.md b/README.md index adc41ad..25a50ed 100644 --- a/README.md +++ b/README.md @@ -9,23 +9,34 @@ Fast, simple image format conversion for files. Built on Pillow - Optional removal of source files after successful conversion. ## Setup + +For normal CLI usage: + +```bash +pipx install . +imc --help +``` + +For local development: + ```bash -pip install -r requirements.txt +python -m pip install -e ".[dev]" +python main.py --help ``` ## Quick Start ```bash -python main.py list-formats -python main.py convert image.webp --to png -python main.py convert images/ --to jpg --recursive --output out -python main.py convert image.png --to webp --remove-source +imc list-formats +imc convert image.webp --to png +imc convert images/ --to jpg --recursive --output out +imc convert image.png --to webp --remove-source ``` ## Commands | Command | Description | Example | | --- | --- | --- | -| `list-formats` | List all supported extensions. | `python main.py list-formats` | -| `convert` | Convert files or directories to a target format. | `python main.py convert images/ --to png` | +| `list-formats` | List readable and writable extensions. | `imc list-formats` | +| `convert` | Convert files or directories to a target format. | `imc convert images/ --to png` | ## Flags (convert) | Flag | Alias | Type | Default | Description | @@ -48,22 +59,22 @@ python main.py convert image.png --to webp --remove-source ## Usage Examples Convert a single file to PNG: ```bash -python main.py convert image.webp --to png +imc convert image.webp --to png ``` Convert a folder recursively and delete originals: ```bash -python main.py convert photos/ --to jpg --recursive --remove-source +imc convert photos/ --to jpg --recursive --remove-source ``` Save outputs to a different directory: ```bash -python main.py convert photos/ --to webp --recursive --output converted/ +imc convert photos/ --to webp --recursive --output converted/ ``` Set quality for JPEG/WEBP/AVIF: ```bash -python main.py convert image.png --to jpg --quality 85 +imc convert image.png --to jpg --quality 85 ``` ## Notes diff --git a/pyproject.toml b/pyproject.toml index 19eb497..2d49ce3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,9 @@ [project] name = "imc-image-converter" -version = "0.0.2-dev" +version = "0.0.2.dev0" description = "Fast, simple image format conversion for files." readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" dependencies = ["Pillow>=10.0.0"] [project.optional-dependencies] @@ -22,4 +22,4 @@ pythonpath = ["."] [tool.black] line-length = 88 -target-version = ["py39"] +target-version = ["py310"]