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 30c29e2..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 -pip install -r requirements.txt +pipx install . +imc --help +``` + +For local development: + +```bash +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 | @@ -39,28 +50,33 @@ 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. +- 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: ```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 +- 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/cli/app.py b/cli/app.py index 243cfc1..c83db69 100644 --- a/cli/app.py +++ b/cli/app.py @@ -4,9 +4,17 @@ 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 + +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", @@ -14,18 +22,28 @@ 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", 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") @@ -33,22 +51,33 @@ def _build_parser() -> argparse.ArgumentParser: return parser + 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 + +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 = 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: - 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, @@ -56,20 +85,24 @@ 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 + 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() @@ -77,7 +110,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/cli/convert.py b/cli/convert.py index afdda91..9e57f7d 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 @@ -21,19 +22,50 @@ class ConvertOptions: overwrite: bool remove_source: bool quality: int | None + output_is_file: bool = False + + +@dataclass(frozen=True) +class Conversion: + source: Path + target: Path + 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) - 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) + if preserve_root_name: + relative = Path(base_dir.name) / relative 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 _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_is_file: + 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: 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,28 +74,79 @@ 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: + +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) - target = _target_path(source, base_dir, options) - if target.exists() and not options.overwrite: - raise FileExistsError(f"Target already exists: {target}") + 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) + ): + conversions.append( + Conversion( + source=source, + target=_target_path( + source, + base_dir, + options, + preserve_root_name=( + recursive_directory_count > 1 and path.is_dir() + ), + ), + target_format=target_format, + ) + ) + return 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}") - 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 conversion.target - return 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: if value is None: diff --git a/cli/utils.py b/cli/utils.py index 1fc2112..69df2c3 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,30 +14,70 @@ def normalize_extension(ext: str) -> str: ext = "." + ext 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] + 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]: + +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)] + 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 0517674..2d49ce3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,25 @@ [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] +dev = ["black==26.5.1", "pytest"] + [project.scripts] imc = "cli.app:main" [build-system] requires = ["setuptools>=68.0"] build-backend = "setuptools.build_meta" + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] + +[tool.black] +line-length = 88 +target-version = ["py310"] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..de3f406 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,60 @@ +import subprocess +import sys +from pathlib import Path + +from PIL import Image + + +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, + ) + + +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] + + 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 + + +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 new file mode 100644 index 0000000..c95cbd5 --- /dev/null +++ b/tests/test_convert.py @@ -0,0 +1,153 @@ +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, + output_is_file: bool = False, +) -> ConvertOptions: + return ConvertOptions( + target_extension=target_extension, + output=output, + recursive=recursive, + overwrite=overwrite, + remove_source=remove_source, + quality=quality, + output_is_file=output_is_file, + ) + + +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() + + +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() + + +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() + + +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() + + +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() + + +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() + + +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() + + +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() + + +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"