From f6e9d8a30709e26992cd6deb628bf929e91b720f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 27 Jun 2025 03:42:25 +0000 Subject: [PATCH] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pillow-cli/.gitignore | 4 +- pillow-cli/build_exe.bat | 2 +- pillow-cli/pillow-cli.spec | 2 +- pillow-cli/pillow_cli.py | 930 ++++++++++++++++------------ pillow-cli/pytest.ini | 4 +- pillow-cli/requirements.txt | 2 +- pillow-cli/tests/conftest.py | 84 +-- pillow-cli/tests/test_pillow_cli.py | 669 ++++++++++---------- 8 files changed, 959 insertions(+), 738 deletions(-) diff --git a/pillow-cli/.gitignore b/pillow-cli/.gitignore index 3c076e3a2..3929c7350 100644 --- a/pillow-cli/.gitignore +++ b/pillow-cli/.gitignore @@ -189,7 +189,7 @@ processed_images/ # But keep README images and documentation images !README*.jpg -!README*.jpeg +!README*.jpeg !README*.png !README*.gif !docs/*.jpg @@ -233,4 +233,4 @@ dist/ *.dmg # But keep our custom spec file -!pillow-cli.spec \ No newline at end of file +!pillow-cli.spec diff --git a/pillow-cli/build_exe.bat b/pillow-cli/build_exe.bat index 280ea6e50..6984eb324 100644 --- a/pillow-cli/build_exe.bat +++ b/pillow-cli/build_exe.bat @@ -42,4 +42,4 @@ if exist "dist\pillow-cli.exe" ( echo ERROR: Build failed. Check the output above for errors. ) -pause \ No newline at end of file +pause diff --git a/pillow-cli/pillow-cli.spec b/pillow-cli/pillow-cli.spec index 93c194862..02ffe3b6d 100644 --- a/pillow-cli/pillow-cli.spec +++ b/pillow-cli/pillow-cli.spec @@ -61,4 +61,4 @@ exe = EXE( codesign_identity=None, entitlements_file=None, icon=None, -) \ No newline at end of file +) diff --git a/pillow-cli/pillow_cli.py b/pillow-cli/pillow_cli.py index b8cc5bf86..bfd0dd83d 100644 --- a/pillow-cli/pillow_cli.py +++ b/pillow-cli/pillow_cli.py @@ -1,60 +1,86 @@ #!/usr/bin/env python3 +from __future__ import annotations + import argparse +import json import os import sys from pathlib import Path -import json -from PIL import Image, ImageFilter, ImageEnhance, ImageDraw, ImageFont, ImageOps -from PIL.ExifTags import TAGS + import numpy as np + +from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont, ImageOps +from PIL.ExifTags import TAGS + try: - from PIL import ImageTk import tkinter as tk from tkinter import ttk + + from PIL import ImageTk + GUI_AVAILABLE = True except ImportError: GUI_AVAILABLE = False + class PillowCLI: """Main CLI class for image processing operations""" - + def __init__(self): - self.supported_formats = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.webp'} - + self.supported_formats = { + ".jpg", + ".jpeg", + ".png", + ".bmp", + ".gif", + ".tiff", + ".webp", + } + def validate_image_path(self, path): """Validate if the path is a valid image file""" if not os.path.exists(path): raise FileNotFoundError(f"Image file not found: {path}") - - if not Path(path).suffix.lower() in self.supported_formats: - raise ValueError(f"Unsupported image format. Supported: {', '.join(self.supported_formats)}") - + + if Path(path).suffix.lower() not in self.supported_formats: + raise ValueError( + f"Unsupported image format. Supported: {', '.join(self.supported_formats)}" + ) + return True - + def load_image(self, path): """Load an image from file path""" self.validate_image_path(path) return Image.open(path) - + def save_image(self, image, output_path, quality=95): """Save image to file with specified quality""" # Ensure output directory exists - os.makedirs(os.path.dirname(output_path) if os.path.dirname(output_path) else '.', exist_ok=True) - + os.makedirs( + os.path.dirname(output_path) if os.path.dirname(output_path) else ".", + exist_ok=True, + ) + # Convert RGBA to RGB if saving as JPEG - if Path(output_path).suffix.lower() in ['.jpg', '.jpeg'] and image.mode == 'RGBA': - background = Image.new('RGB', image.size, (255, 255, 255)) + if ( + Path(output_path).suffix.lower() in [".jpg", ".jpeg"] + and image.mode == "RGBA" + ): + background = Image.new("RGB", image.size, (255, 255, 255)) background.paste(image, mask=image.split()[-1]) image = background - + image.save(output_path, quality=quality, optimize=True) print(f"Image saved to: {output_path}") - - def resize_image(self, input_path, output_path, width=None, height=None, maintain_aspect=True): + + def resize_image( + self, input_path, output_path, width=None, height=None, maintain_aspect=True + ): """Resize image with optional aspect ratio preservation""" image = self.load_image(input_path) original_width, original_height = image.size - + if width and height: if maintain_aspect: # Calculate the aspect ratio @@ -72,228 +98,261 @@ class PillowCLI: new_size = (int(height * aspect_ratio), height) else: raise ValueError("Must specify at least width or height") - + resized_image = image.resize(new_size, Image.Resampling.LANCZOS) self.save_image(resized_image, output_path) - print(f"Resized from {original_width}x{original_height} to {new_size[0]}x{new_size[1]}") - + print( + f"Resized from {original_width}x{original_height} to {new_size[0]}x{new_size[1]}" + ) + def apply_filters(self, input_path, output_path, filter_type): """Apply various filters to the image""" image = self.load_image(input_path) - + filters = { - 'blur': ImageFilter.BLUR, - 'contour': ImageFilter.CONTOUR, - 'detail': ImageFilter.DETAIL, - 'edge_enhance': ImageFilter.EDGE_ENHANCE, - 'edge_enhance_more': ImageFilter.EDGE_ENHANCE_MORE, - 'emboss': ImageFilter.EMBOSS, - 'find_edges': ImageFilter.FIND_EDGES, - 'sharpen': ImageFilter.SHARPEN, - 'smooth': ImageFilter.SMOOTH, - 'smooth_more': ImageFilter.SMOOTH_MORE, - 'gaussian_blur': ImageFilter.GaussianBlur(radius=2), - 'unsharp_mask': ImageFilter.UnsharpMask(radius=2, percent=150, threshold=3) + "blur": ImageFilter.BLUR, + "contour": ImageFilter.CONTOUR, + "detail": ImageFilter.DETAIL, + "edge_enhance": ImageFilter.EDGE_ENHANCE, + "edge_enhance_more": ImageFilter.EDGE_ENHANCE_MORE, + "emboss": ImageFilter.EMBOSS, + "find_edges": ImageFilter.FIND_EDGES, + "sharpen": ImageFilter.SHARPEN, + "smooth": ImageFilter.SMOOTH, + "smooth_more": ImageFilter.SMOOTH_MORE, + "gaussian_blur": ImageFilter.GaussianBlur(radius=2), + "unsharp_mask": ImageFilter.UnsharpMask(radius=2, percent=150, threshold=3), } - + if filter_type not in filters: - raise ValueError(f"Unknown filter: {filter_type}. Available: {', '.join(filters.keys())}") - + raise ValueError( + f"Unknown filter: {filter_type}. Available: {', '.join(filters.keys())}" + ) + filtered_image = image.filter(filters[filter_type]) self.save_image(filtered_image, output_path) print(f"Applied {filter_type} filter") - - def adjust_image(self, input_path, output_path, brightness=1.0, contrast=1.0, saturation=1.0, sharpness=1.0): + + def adjust_image( + self, + input_path, + output_path, + brightness=1.0, + contrast=1.0, + saturation=1.0, + sharpness=1.0, + ): """Adjust image brightness, contrast, saturation, and sharpness""" image = self.load_image(input_path) - + # Apply enhancements if brightness != 1.0: enhancer = ImageEnhance.Brightness(image) image = enhancer.enhance(brightness) - + if contrast != 1.0: enhancer = ImageEnhance.Contrast(image) image = enhancer.enhance(contrast) - + if saturation != 1.0: enhancer = ImageEnhance.Color(image) image = enhancer.enhance(saturation) - + if sharpness != 1.0: enhancer = ImageEnhance.Sharpness(image) image = enhancer.enhance(sharpness) - + self.save_image(image, output_path) - print(f"Adjusted image - Brightness: {brightness}, Contrast: {contrast}, Saturation: {saturation}, Sharpness: {sharpness}") - + print( + f"Adjusted image - Brightness: {brightness}, Contrast: {contrast}, Saturation: {saturation}, Sharpness: {sharpness}" + ) + def crop_image(self, input_path, output_path, x, y, width, height): """Crop image to specified dimensions""" image = self.load_image(input_path) - + # Make sure we're not trying to crop outside the image bounds img_width, img_height = image.size if x + width > img_width or y + height > img_height: - raise ValueError(f"Crop dimensions exceed image size ({img_width}x{img_height})") - + raise ValueError( + f"Crop dimensions exceed image size ({img_width}x{img_height})" + ) + cropped_image = image.crop((x, y, x + width, y + height)) self.save_image(cropped_image, output_path) print(f"Cropped to {width}x{height} from position ({x}, {y})") - + def rotate_image(self, input_path, output_path, angle, expand=True): """Rotate image by specified angle""" image = self.load_image(input_path) - rotated_image = image.rotate(angle, expand=expand, fillcolor='white') + rotated_image = image.rotate(angle, expand=expand, fillcolor="white") self.save_image(rotated_image, output_path) print(f"Rotated image by {angle} degrees") - - def flip_image(self, input_path, output_path, direction='horizontal'): + + def flip_image(self, input_path, output_path, direction="horizontal"): """Flip image horizontally or vertically""" image = self.load_image(input_path) - - if direction == 'horizontal': + + if direction == "horizontal": flipped_image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) - elif direction == 'vertical': + elif direction == "vertical": flipped_image = image.transpose(Image.Transpose.FLIP_TOP_BOTTOM) else: raise ValueError("Direction must be 'horizontal' or 'vertical'") - + self.save_image(flipped_image, output_path) print(f"Flipped image {direction}ly") - - def convert_format(self, input_path, output_path, format_type='PNG'): + + def convert_format(self, input_path, output_path, format_type="PNG"): """Convert image to different format""" image = self.load_image(input_path) - + # Handle transparency for JPEG conversion - if format_type.upper() == 'JPEG' and image.mode in ('RGBA', 'LA'): - background = Image.new('RGB', image.size, (255, 255, 255)) - background.paste(image, mask=image.split()[-1] if image.mode == 'RGBA' else None) + if format_type.upper() == "JPEG" and image.mode in ("RGBA", "LA"): + background = Image.new("RGB", image.size, (255, 255, 255)) + background.paste( + image, mask=image.split()[-1] if image.mode == "RGBA" else None + ) image = background - + self.save_image(image, output_path) print(f"Converted to {format_type.upper()} format") - + def create_thumbnail(self, input_path, output_path, size=(128, 128)): """Create thumbnail of the image""" image = self.load_image(input_path) image.thumbnail(size, Image.Resampling.LANCZOS) self.save_image(image, output_path) print(f"Created thumbnail with size {size}") - - def add_watermark(self, input_path, output_path, watermark_text, position='bottom-right', opacity=128): + + def add_watermark( + self, + input_path, + output_path, + watermark_text, + position="bottom-right", + opacity=128, + ): """Add text watermark to image""" image = self.load_image(input_path) - + # Create a transparent overlay - overlay = Image.new('RGBA', image.size, (255, 255, 255, 0)) + overlay = Image.new("RGBA", image.size, (255, 255, 255, 0)) draw = ImageDraw.Draw(overlay) - + # Try to use a default font, fallback to default if not available try: font = ImageFont.truetype("arial.ttf", 36) except OSError: font = ImageFont.load_default() - + # Get text size bbox = draw.textbbox((0, 0), watermark_text, font=font) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] - + # Calculate position img_width, img_height = image.size positions = { - 'top-left': (10, 10), - 'top-right': (img_width - text_width - 10, 10), - 'bottom-left': (10, img_height - text_height - 10), - 'bottom-right': (img_width - text_width - 10, img_height - text_height - 10), - 'center': ((img_width - text_width) // 2, (img_height - text_height) // 2) + "top-left": (10, 10), + "top-right": (img_width - text_width - 10, 10), + "bottom-left": (10, img_height - text_height - 10), + "bottom-right": ( + img_width - text_width - 10, + img_height - text_height - 10, + ), + "center": ((img_width - text_width) // 2, (img_height - text_height) // 2), } - + if position not in positions: - raise ValueError(f"Invalid position. Available: {', '.join(positions.keys())}") - + raise ValueError( + f"Invalid position. Available: {', '.join(positions.keys())}" + ) + x, y = positions[position] - + # Draw text with semi-transparent background - draw.rectangle([x-5, y-5, x+text_width+5, y+text_height+5], fill=(0, 0, 0, opacity//2)) + draw.rectangle( + [x - 5, y - 5, x + text_width + 5, y + text_height + 5], + fill=(0, 0, 0, opacity // 2), + ) draw.text((x, y), watermark_text, font=font, fill=(255, 255, 255, opacity)) - + # Composite the overlay with the original image - watermarked_image = Image.alpha_composite(image.convert('RGBA'), overlay) + watermarked_image = Image.alpha_composite(image.convert("RGBA"), overlay) self.save_image(watermarked_image, output_path) print(f"Added watermark '{watermark_text}' at {position}") - + def extract_metadata(self, input_path): """Extract and display image metadata""" image = self.load_image(input_path) - + metadata = { - 'filename': os.path.basename(input_path), - 'format': image.format, - 'mode': image.mode, - 'size': image.size, - 'width': image.width, - 'height': image.height, + "filename": os.path.basename(input_path), + "format": image.format, + "mode": image.mode, + "size": image.size, + "width": image.width, + "height": image.height, } - + # Extract EXIF data if available exif_data = {} - if hasattr(image, '_getexif') and image._getexif(): + if hasattr(image, "_getexif") and image._getexif(): exif = image._getexif() for tag_id, value in exif.items(): tag = TAGS.get(tag_id, tag_id) exif_data[tag] = value - + if exif_data: - metadata['exif'] = exif_data - + metadata["exif"] = exif_data + print(json.dumps(metadata, indent=2, default=str)) return metadata - + def create_collage(self, input_paths, output_path, cols=2, padding=10): """Create a collage from multiple images""" if not input_paths: raise ValueError("No input images provided") - + # Load all images images = [self.load_image(path) for path in input_paths] - + # Calculate grid dimensions rows = (len(images) + cols - 1) // cols - + # Find the maximum dimensions max_width = max(img.width for img in images) max_height = max(img.height for img in images) - + # Resize all images to the same size resized_images = [] for img in images: resized_img = img.resize((max_width, max_height), Image.Resampling.LANCZOS) resized_images.append(resized_img) - + # Calculate collage dimensions collage_width = cols * max_width + (cols + 1) * padding collage_height = rows * max_height + (rows + 1) * padding - + # Create collage - collage = Image.new('RGB', (collage_width, collage_height), 'white') - + collage = Image.new("RGB", (collage_width, collage_height), "white") + for i, img in enumerate(resized_images): row = i // cols col = i % cols x = col * (max_width + padding) + padding y = row * (max_height + padding) + padding collage.paste(img, (x, y)) - + self.save_image(collage, output_path) print(f"Created collage with {len(images)} images ({rows}x{cols} grid)") - - def apply_artistic_effects(self, input_path, output_path, effect='sepia'): + + def apply_artistic_effects(self, input_path, output_path, effect="sepia"): """Apply artistic effects to the image""" - image = self.load_image(input_path).convert('RGB') - - if effect == 'sepia': + image = self.load_image(input_path).convert("RGB") + + if effect == "sepia": # Convert to sepia pixels = image.load() for y in range(image.height): @@ -303,185 +362,193 @@ class PillowCLI: tg = int(0.349 * r + 0.686 * g + 0.168 * b) tb = int(0.272 * r + 0.534 * g + 0.131 * b) pixels[x, y] = (min(255, tr), min(255, tg), min(255, tb)) - - elif effect == 'grayscale': + + elif effect == "grayscale": image = ImageOps.grayscale(image) - - elif effect == 'invert': + + elif effect == "invert": image = ImageOps.invert(image) - - elif effect == 'posterize': + + elif effect == "posterize": image = ImageOps.posterize(image, 4) - - elif effect == 'solarize': + + elif effect == "solarize": image = ImageOps.solarize(image, threshold=128) - + else: - raise ValueError(f"Unknown effect: {effect}. Available: sepia, grayscale, invert, posterize, solarize") - + raise ValueError( + f"Unknown effect: {effect}. Available: sepia, grayscale, invert, posterize, solarize" + ) + self.save_image(image, output_path) print(f"Applied {effect} effect") - + def batch_process(self, input_dir, output_dir, operation, **kwargs): """Process multiple images in a directory""" input_path = Path(input_dir) output_path = Path(output_dir) - + if not input_path.exists(): raise FileNotFoundError(f"Input directory not found: {input_dir}") - + output_path.mkdir(parents=True, exist_ok=True) - + # Find all image files image_files = [] for ext in self.supported_formats: image_files.extend(input_path.glob(f"*{ext}")) image_files.extend(input_path.glob(f"*{ext.upper()}")) - + if not image_files: print("No image files found in the input directory") return - + print(f"Processing {len(image_files)} images...") - + for i, image_file in enumerate(image_files, 1): try: output_file = output_path / image_file.name print(f"[{i}/{len(image_files)}] Processing: {image_file.name}") - - if operation == 'resize': + + if operation == "resize": self.resize_image(str(image_file), str(output_file), **kwargs) - elif operation == 'filter': + elif operation == "filter": self.apply_filters(str(image_file), str(output_file), **kwargs) - elif operation == 'adjust': + elif operation == "adjust": self.adjust_image(str(image_file), str(output_file), **kwargs) - elif operation == 'effect': - self.apply_artistic_effects(str(image_file), str(output_file), **kwargs) - elif operation == 'thumbnail': + elif operation == "effect": + self.apply_artistic_effects( + str(image_file), str(output_file), **kwargs + ) + elif operation == "thumbnail": self.create_thumbnail(str(image_file), str(output_file), **kwargs) else: print(f"Unknown batch operation: {operation}") return - + except Exception as e: print(f"Error processing {image_file.name}: {e}") - + print(f"Batch processing complete! Output saved to: {output_dir}") - + def simple_gui(self): """Launch a simple GUI for image processing""" if not GUI_AVAILABLE: print("GUI not available. Install tkinter to use this feature.") return - + def process_image(): input_file = input_var.get() output_file = output_var.get() operation = operation_var.get() - + if not input_file or not output_file: status_var.set("Please select input and output files") return - + try: if operation == "Resize": self.resize_image(input_file, output_file, width=300, height=300) elif operation == "Blur": - self.apply_filters(input_file, output_file, 'blur') + self.apply_filters(input_file, output_file, "blur") elif operation == "Sepia": - self.apply_artistic_effects(input_file, output_file, 'sepia') + self.apply_artistic_effects(input_file, output_file, "sepia") elif operation == "Grayscale": - self.apply_artistic_effects(input_file, output_file, 'grayscale') - + self.apply_artistic_effects(input_file, output_file, "grayscale") + status_var.set(f"Success! Processed image saved to {output_file}") except Exception as e: status_var.set(f"Error: {str(e)}") - + root = tk.Tk() root.title("Pillow CLI - Simple GUI") root.geometry("600x400") - + # Variables input_var = tk.StringVar() output_var = tk.StringVar() operation_var = tk.StringVar(value="Resize") status_var = tk.StringVar(value="Ready") - + # GUI elements ttk.Label(root, text="Input Image:").pack(pady=5) ttk.Entry(root, textvariable=input_var, width=60).pack(pady=5) - + ttk.Label(root, text="Output Image:").pack(pady=5) ttk.Entry(root, textvariable=output_var, width=60).pack(pady=5) - + ttk.Label(root, text="Operation:").pack(pady=5) - ttk.Combobox(root, textvariable=operation_var, - values=["Resize", "Blur", "Sepia", "Grayscale"]).pack(pady=5) - + ttk.Combobox( + root, + textvariable=operation_var, + values=["Resize", "Blur", "Sepia", "Grayscale"], + ).pack(pady=5) + ttk.Button(root, text="Process Image", command=process_image).pack(pady=20) - + ttk.Label(root, text="Status:").pack(pady=5) ttk.Label(root, textvariable=status_var, foreground="blue").pack(pady=5) - + root.mainloop() - + def analyze_histogram(self, input_path, output_path=None): """Analyze and optionally save image histogram""" - image = self.load_image(input_path).convert('RGB') - + image = self.load_image(input_path).convert("RGB") + # Calculate histogram for each channel hist_r = image.histogram()[0:256] hist_g = image.histogram()[256:512] hist_b = image.histogram()[512:768] - + histogram_data = { - 'red_channel': hist_r, - 'green_channel': hist_g, - 'blue_channel': hist_b, - 'total_pixels': image.width * image.height + "red_channel": hist_r, + "green_channel": hist_g, + "blue_channel": hist_b, + "total_pixels": image.width * image.height, } - + print(f"Image: {os.path.basename(input_path)}") print(f"Dimensions: {image.width}x{image.height}") print(f"Total pixels: {histogram_data['total_pixels']}") - + # Basic statistics avg_r = sum(i * hist_r[i] for i in range(256)) / sum(hist_r) avg_g = sum(i * hist_g[i] for i in range(256)) / sum(hist_g) avg_b = sum(i * hist_b[i] for i in range(256)) / sum(hist_b) - + print(f"Average RGB values: R={avg_r:.1f}, G={avg_g:.1f}, B={avg_b:.1f}") - + if output_path: - with open(output_path, 'w') as f: + with open(output_path, "w") as f: json.dump(histogram_data, f, indent=2) print(f"Histogram data saved to: {output_path}") - + return histogram_data - + def extract_color_palette(self, input_path, num_colors=5): """Extract dominant colors from image""" - image = self.load_image(input_path).convert('RGB') - + image = self.load_image(input_path).convert("RGB") + # Resize image for faster processing image = image.resize((150, 150)) - + # Convert to numpy array and reshape img_array = np.array(image) pixels = img_array.reshape(-1, 3) - + # Use k-means clustering to find dominant colors try: from sklearn.cluster import KMeans + kmeans = KMeans(n_clusters=num_colors, random_state=42, n_init=10) kmeans.fit(pixels) colors = kmeans.cluster_centers_.astype(int) - + print(f"Dominant colors in {os.path.basename(input_path)}:") for i, color in enumerate(colors, 1): - hex_color = '#{:02x}{:02x}{:02x}'.format(color[0], color[1], color[2]) + hex_color = f"#{color[0]:02x}{color[1]:02x}{color[2]:02x}" print(f" {i}. RGB({color[0]}, {color[1]}, {color[2]}) - {hex_color}") - + return colors.tolist() except ImportError: print("scikit-learn not installed. Using simple color extraction...") @@ -490,330 +557,431 @@ class PillowCLI: for pixel in pixels: color = tuple(pixel) unique_colors[color] = unique_colors.get(color, 0) + 1 - + # Get most common colors - sorted_colors = sorted(unique_colors.items(), key=lambda x: x[1], reverse=True) + sorted_colors = sorted( + unique_colors.items(), key=lambda x: x[1], reverse=True + ) top_colors = [list(color[0]) for color in sorted_colors[:num_colors]] - + print(f"Most common colors in {os.path.basename(input_path)}:") for i, color in enumerate(top_colors, 1): - hex_color = '#{:02x}{:02x}{:02x}'.format(color[0], color[1], color[2]) + hex_color = f"#{color[0]:02x}{color[1]:02x}{color[2]:02x}" print(f" {i}. RGB({color[0]}, {color[1]}, {color[2]}) - {hex_color}") - + return top_colors - - def create_border(self, input_path, output_path, border_width=10, border_color='black'): + + def create_border( + self, input_path, output_path, border_width=10, border_color="black" + ): """Add border around image""" image = self.load_image(input_path) - + # Parse border color if isinstance(border_color, str): - if border_color.startswith('#'): + if border_color.startswith("#"): # Hex color - border_color = tuple(int(border_color[i:i+2], 16) for i in (1, 3, 5)) - elif border_color in ['black', 'white', 'red', 'green', 'blue']: + border_color = tuple( + int(border_color[i : i + 2], 16) for i in (1, 3, 5) + ) + elif border_color in ["black", "white", "red", "green", "blue"]: # Named colors color_map = { - 'black': (0, 0, 0), - 'white': (255, 255, 255), - 'red': (255, 0, 0), - 'green': (0, 255, 0), - 'blue': (0, 0, 255) + "black": (0, 0, 0), + "white": (255, 255, 255), + "red": (255, 0, 0), + "green": (0, 255, 0), + "blue": (0, 0, 255), } border_color = color_map[border_color] - + bordered_image = ImageOps.expand(image, border=border_width, fill=border_color) self.save_image(bordered_image, output_path) print(f"Added {border_width}px border with color {border_color}") - - def create_composite(self, background_path, overlay_path, output_path, position=(0, 0), opacity=1.0): + + def create_composite( + self, background_path, overlay_path, output_path, position=(0, 0), opacity=1.0 + ): """Composite two images together""" - background = self.load_image(background_path).convert('RGBA') - overlay = self.load_image(overlay_path).convert('RGBA') - + background = self.load_image(background_path).convert("RGBA") + overlay = self.load_image(overlay_path).convert("RGBA") + # Adjust overlay opacity if opacity < 1.0: overlay = overlay.copy() overlay.putalpha(int(255 * opacity)) - + # Create composite - composite = Image.new('RGBA', background.size, (0, 0, 0, 0)) + composite = Image.new("RGBA", background.size, (0, 0, 0, 0)) composite.paste(background, (0, 0)) composite.paste(overlay, position, overlay) - + self.save_image(composite, output_path) print(f"Created composite image at position {position} with opacity {opacity}") - + def apply_vignette(self, input_path, output_path, strength=0.5): """Apply vignette effect to image""" - image = self.load_image(input_path).convert('RGBA') + image = self.load_image(input_path).convert("RGBA") width, height = image.size - + # Create vignette mask - mask = Image.new('L', (width, height), 0) + mask = Image.new("L", (width, height), 0) mask_draw = ImageDraw.Draw(mask) - + # Calculate center and radii center_x, center_y = width // 2, height // 2 max_radius = min(center_x, center_y) - + # Draw gradient circles steps = 100 for i in range(steps): radius = max_radius * (i / steps) alpha = int(255 * (1 - strength * (i / steps))) - mask_draw.ellipse([center_x - radius, center_y - radius, - center_x + radius, center_y + radius], fill=alpha) - + mask_draw.ellipse( + [ + center_x - radius, + center_y - radius, + center_x + radius, + center_y + radius, + ], + fill=alpha, + ) + # Apply vignette - vignette_overlay = Image.new('RGBA', (width, height), (0, 0, 0, 0)) + vignette_overlay = Image.new("RGBA", (width, height), (0, 0, 0, 0)) vignette_overlay.putalpha(mask) - + result = Image.alpha_composite(image, vignette_overlay) self.save_image(result, output_path) print(f"Applied vignette effect with strength {strength}") - - def create_contact_sheet(self, input_paths, output_path, sheet_width=1200, margin=10): + + def create_contact_sheet( + self, input_paths, output_path, sheet_width=1200, margin=10 + ): """Create a contact sheet from multiple images""" if not input_paths: raise ValueError("No input images provided") - + images = [self.load_image(path) for path in input_paths] - + # Calculate thumbnail size based on sheet width and number of images cols = min(4, len(images)) # Max 4 columns rows = (len(images) + cols - 1) // cols - + thumb_width = (sheet_width - margin * (cols + 1)) // cols thumb_height = thumb_width # Square thumbnails - + # Create contact sheet sheet_height = rows * thumb_height + margin * (rows + 1) - contact_sheet = Image.new('RGB', (sheet_width, sheet_height), 'white') - + contact_sheet = Image.new("RGB", (sheet_width, sheet_height), "white") + for i, img in enumerate(images): # Create thumbnail img.thumbnail((thumb_width, thumb_height), Image.Resampling.LANCZOS) - + # Calculate position row = i // cols col = i % cols x = col * (thumb_width + margin) + margin y = row * (thumb_height + margin) + margin - + # Center the thumbnail if it's smaller than the allocated space if img.width < thumb_width: x += (thumb_width - img.width) // 2 if img.height < thumb_height: y += (thumb_height - img.height) // 2 - + contact_sheet.paste(img, (x, y)) - + self.save_image(contact_sheet, output_path) print(f"Created contact sheet with {len(images)} images ({rows}x{cols} grid)") - + def split_channels(self, input_path, output_dir): """Split image into separate color channels""" - image = self.load_image(input_path).convert('RGB') + image = self.load_image(input_path).convert("RGB") output_path = Path(output_dir) output_path.mkdir(parents=True, exist_ok=True) - + base_name = Path(input_path).stem - + # Split channels r, g, b = image.split() - + # Save each channel r_path = output_path / f"{base_name}_red.png" g_path = output_path / f"{base_name}_green.png" b_path = output_path / f"{base_name}_blue.png" - + # Convert to RGB for saving (grayscale channels) - r_img = Image.merge('RGB', [r, Image.new('L', image.size, 0), Image.new('L', image.size, 0)]) - g_img = Image.merge('RGB', [Image.new('L', image.size, 0), g, Image.new('L', image.size, 0)]) - b_img = Image.merge('RGB', [Image.new('L', image.size, 0), Image.new('L', image.size, 0), b]) - + r_img = Image.merge( + "RGB", [r, Image.new("L", image.size, 0), Image.new("L", image.size, 0)] + ) + g_img = Image.merge( + "RGB", [Image.new("L", image.size, 0), g, Image.new("L", image.size, 0)] + ) + b_img = Image.merge( + "RGB", [Image.new("L", image.size, 0), Image.new("L", image.size, 0), b] + ) + self.save_image(r_img, str(r_path)) self.save_image(g_img, str(g_path)) self.save_image(b_img, str(b_path)) - + print(f"Split channels saved to {output_dir}") return [str(r_path), str(g_path), str(b_path)] + def main(): """Main function to handle command line arguments""" - parser = argparse.ArgumentParser(description="Pillow CLI - Comprehensive Image Processing Tool") - subparsers = parser.add_subparsers(dest='command', help='Available commands') - + parser = argparse.ArgumentParser( + description="Pillow CLI - Comprehensive Image Processing Tool" + ) + subparsers = parser.add_subparsers(dest="command", help="Available commands") + # Resize command - resize_parser = subparsers.add_parser('resize', help='Resize image') - resize_parser.add_argument('input', help='Input image path') - resize_parser.add_argument('output', help='Output image path') - resize_parser.add_argument('--width', type=int, help='Target width') - resize_parser.add_argument('--height', type=int, help='Target height') - resize_parser.add_argument('--no-aspect', action='store_true', help='Don\'t maintain aspect ratio') - + resize_parser = subparsers.add_parser("resize", help="Resize image") + resize_parser.add_argument("input", help="Input image path") + resize_parser.add_argument("output", help="Output image path") + resize_parser.add_argument("--width", type=int, help="Target width") + resize_parser.add_argument("--height", type=int, help="Target height") + resize_parser.add_argument( + "--no-aspect", action="store_true", help="Don't maintain aspect ratio" + ) + # Filter command - filter_parser = subparsers.add_parser('filter', help='Apply filter to image') - filter_parser.add_argument('input', help='Input image path') - filter_parser.add_argument('output', help='Output image path') - filter_parser.add_argument('type', choices=['blur', 'contour', 'detail', 'edge_enhance', - 'edge_enhance_more', 'emboss', 'find_edges', - 'sharpen', 'smooth', 'smooth_more', 'gaussian_blur', - 'unsharp_mask'], help='Filter type') - + filter_parser = subparsers.add_parser("filter", help="Apply filter to image") + filter_parser.add_argument("input", help="Input image path") + filter_parser.add_argument("output", help="Output image path") + filter_parser.add_argument( + "type", + choices=[ + "blur", + "contour", + "detail", + "edge_enhance", + "edge_enhance_more", + "emboss", + "find_edges", + "sharpen", + "smooth", + "smooth_more", + "gaussian_blur", + "unsharp_mask", + ], + help="Filter type", + ) + # Adjust command - adjust_parser = subparsers.add_parser('adjust', help='Adjust image properties') - adjust_parser.add_argument('input', help='Input image path') - adjust_parser.add_argument('output', help='Output image path') - adjust_parser.add_argument('--brightness', type=float, default=1.0, help='Brightness factor (default: 1.0)') - adjust_parser.add_argument('--contrast', type=float, default=1.0, help='Contrast factor (default: 1.0)') - adjust_parser.add_argument('--saturation', type=float, default=1.0, help='Saturation factor (default: 1.0)') - adjust_parser.add_argument('--sharpness', type=float, default=1.0, help='Sharpness factor (default: 1.0)') - + adjust_parser = subparsers.add_parser("adjust", help="Adjust image properties") + adjust_parser.add_argument("input", help="Input image path") + adjust_parser.add_argument("output", help="Output image path") + adjust_parser.add_argument( + "--brightness", type=float, default=1.0, help="Brightness factor (default: 1.0)" + ) + adjust_parser.add_argument( + "--contrast", type=float, default=1.0, help="Contrast factor (default: 1.0)" + ) + adjust_parser.add_argument( + "--saturation", type=float, default=1.0, help="Saturation factor (default: 1.0)" + ) + adjust_parser.add_argument( + "--sharpness", type=float, default=1.0, help="Sharpness factor (default: 1.0)" + ) + # Crop command - crop_parser = subparsers.add_parser('crop', help='Crop image') - crop_parser.add_argument('input', help='Input image path') - crop_parser.add_argument('output', help='Output image path') - crop_parser.add_argument('x', type=int, help='X coordinate') - crop_parser.add_argument('y', type=int, help='Y coordinate') - crop_parser.add_argument('width', type=int, help='Crop width') - crop_parser.add_argument('height', type=int, help='Crop height') - + crop_parser = subparsers.add_parser("crop", help="Crop image") + crop_parser.add_argument("input", help="Input image path") + crop_parser.add_argument("output", help="Output image path") + crop_parser.add_argument("x", type=int, help="X coordinate") + crop_parser.add_argument("y", type=int, help="Y coordinate") + crop_parser.add_argument("width", type=int, help="Crop width") + crop_parser.add_argument("height", type=int, help="Crop height") + # Rotate command - rotate_parser = subparsers.add_parser('rotate', help='Rotate image') - rotate_parser.add_argument('input', help='Input image path') - rotate_parser.add_argument('output', help='Output image path') - rotate_parser.add_argument('angle', type=float, help='Rotation angle in degrees') - rotate_parser.add_argument('--no-expand', action='store_true', help='Don\'t expand canvas') - + rotate_parser = subparsers.add_parser("rotate", help="Rotate image") + rotate_parser.add_argument("input", help="Input image path") + rotate_parser.add_argument("output", help="Output image path") + rotate_parser.add_argument("angle", type=float, help="Rotation angle in degrees") + rotate_parser.add_argument( + "--no-expand", action="store_true", help="Don't expand canvas" + ) + # Flip command - flip_parser = subparsers.add_parser('flip', help='Flip image') - flip_parser.add_argument('input', help='Input image path') - flip_parser.add_argument('output', help='Output image path') - flip_parser.add_argument('direction', choices=['horizontal', 'vertical'], help='Flip direction') - + flip_parser = subparsers.add_parser("flip", help="Flip image") + flip_parser.add_argument("input", help="Input image path") + flip_parser.add_argument("output", help="Output image path") + flip_parser.add_argument( + "direction", choices=["horizontal", "vertical"], help="Flip direction" + ) + # Convert command - convert_parser = subparsers.add_parser('convert', help='Convert image format') - convert_parser.add_argument('input', help='Input image path') - convert_parser.add_argument('output', help='Output image path') - convert_parser.add_argument('--format', default='PNG', help='Target format (default: PNG)') - + convert_parser = subparsers.add_parser("convert", help="Convert image format") + convert_parser.add_argument("input", help="Input image path") + convert_parser.add_argument("output", help="Output image path") + convert_parser.add_argument( + "--format", default="PNG", help="Target format (default: PNG)" + ) + # Thumbnail command - thumbnail_parser = subparsers.add_parser('thumbnail', help='Create thumbnail') - thumbnail_parser.add_argument('input', help='Input image path') - thumbnail_parser.add_argument('output', help='Output image path') - thumbnail_parser.add_argument('--size', type=int, nargs=2, default=[128, 128], help='Thumbnail size (width height)') - + thumbnail_parser = subparsers.add_parser("thumbnail", help="Create thumbnail") + thumbnail_parser.add_argument("input", help="Input image path") + thumbnail_parser.add_argument("output", help="Output image path") + thumbnail_parser.add_argument( + "--size", + type=int, + nargs=2, + default=[128, 128], + help="Thumbnail size (width height)", + ) + # Watermark command - watermark_parser = subparsers.add_parser('watermark', help='Add watermark') - watermark_parser.add_argument('input', help='Input image path') - watermark_parser.add_argument('output', help='Output image path') - watermark_parser.add_argument('text', help='Watermark text') - watermark_parser.add_argument('--position', choices=['top-left', 'top-right', 'bottom-left', - 'bottom-right', 'center'], - default='bottom-right', help='Watermark position') - watermark_parser.add_argument('--opacity', type=int, default=128, help='Watermark opacity (0-255)') - + watermark_parser = subparsers.add_parser("watermark", help="Add watermark") + watermark_parser.add_argument("input", help="Input image path") + watermark_parser.add_argument("output", help="Output image path") + watermark_parser.add_argument("text", help="Watermark text") + watermark_parser.add_argument( + "--position", + choices=["top-left", "top-right", "bottom-left", "bottom-right", "center"], + default="bottom-right", + help="Watermark position", + ) + watermark_parser.add_argument( + "--opacity", type=int, default=128, help="Watermark opacity (0-255)" + ) + # Metadata command - metadata_parser = subparsers.add_parser('metadata', help='Extract image metadata') - metadata_parser.add_argument('input', help='Input image path') - + metadata_parser = subparsers.add_parser("metadata", help="Extract image metadata") + metadata_parser.add_argument("input", help="Input image path") + # Collage command - collage_parser = subparsers.add_parser('collage', help='Create collage from multiple images') - collage_parser.add_argument('output', help='Output image path') - collage_parser.add_argument('inputs', nargs='+', help='Input image paths') - collage_parser.add_argument('--cols', type=int, default=2, help='Number of columns') - collage_parser.add_argument('--padding', type=int, default=10, help='Padding between images') - + collage_parser = subparsers.add_parser( + "collage", help="Create collage from multiple images" + ) + collage_parser.add_argument("output", help="Output image path") + collage_parser.add_argument("inputs", nargs="+", help="Input image paths") + collage_parser.add_argument("--cols", type=int, default=2, help="Number of columns") + collage_parser.add_argument( + "--padding", type=int, default=10, help="Padding between images" + ) + # Effect command - effect_parser = subparsers.add_parser('effect', help='Apply artistic effects') - effect_parser.add_argument('input', help='Input image path') - effect_parser.add_argument('output', help='Output image path') - effect_parser.add_argument('type', choices=['sepia', 'grayscale', 'invert', 'posterize', 'solarize'], - help='Effect type') - + effect_parser = subparsers.add_parser("effect", help="Apply artistic effects") + effect_parser.add_argument("input", help="Input image path") + effect_parser.add_argument("output", help="Output image path") + effect_parser.add_argument( + "type", + choices=["sepia", "grayscale", "invert", "posterize", "solarize"], + help="Effect type", + ) + # Batch command - batch_parser = subparsers.add_parser('batch', help='Batch process images') - batch_parser.add_argument('input_dir', help='Input directory') - batch_parser.add_argument('output_dir', help='Output directory') - batch_parser.add_argument('operation', choices=['resize', 'filter', 'adjust', 'effect', 'thumbnail'], - help='Batch operation') - batch_parser.add_argument('--width', type=int, help='Width for resize operation') - batch_parser.add_argument('--height', type=int, help='Height for resize operation') - batch_parser.add_argument('--filter-type', help='Filter type for filter operation') - batch_parser.add_argument('--effect-type', help='Effect type for effect operation') - batch_parser.add_argument('--size', type=int, nargs=2, default=[128, 128], help='Size for thumbnail operation') - + batch_parser = subparsers.add_parser("batch", help="Batch process images") + batch_parser.add_argument("input_dir", help="Input directory") + batch_parser.add_argument("output_dir", help="Output directory") + batch_parser.add_argument( + "operation", + choices=["resize", "filter", "adjust", "effect", "thumbnail"], + help="Batch operation", + ) + batch_parser.add_argument("--width", type=int, help="Width for resize operation") + batch_parser.add_argument("--height", type=int, help="Height for resize operation") + batch_parser.add_argument("--filter-type", help="Filter type for filter operation") + batch_parser.add_argument("--effect-type", help="Effect type for effect operation") + batch_parser.add_argument( + "--size", + type=int, + nargs=2, + default=[128, 128], + help="Size for thumbnail operation", + ) + # GUI command - gui_parser = subparsers.add_parser('gui', help='Launch simple GUI') - + gui_parser = subparsers.add_parser("gui", help="Launch simple GUI") + args = parser.parse_args() - + if not args.command: parser.print_help() return - + cli = PillowCLI() - + try: - if args.command == 'resize': - cli.resize_image(args.input, args.output, args.width, args.height, - maintain_aspect=not args.no_aspect) - - elif args.command == 'filter': + if args.command == "resize": + cli.resize_image( + args.input, + args.output, + args.width, + args.height, + maintain_aspect=not args.no_aspect, + ) + + elif args.command == "filter": cli.apply_filters(args.input, args.output, args.type) - - elif args.command == 'adjust': - cli.adjust_image(args.input, args.output, args.brightness, args.contrast, - args.saturation, args.sharpness) - - elif args.command == 'crop': - cli.crop_image(args.input, args.output, args.x, args.y, args.width, args.height) - - elif args.command == 'rotate': - cli.rotate_image(args.input, args.output, args.angle, expand=not args.no_expand) - - elif args.command == 'flip': + + elif args.command == "adjust": + cli.adjust_image( + args.input, + args.output, + args.brightness, + args.contrast, + args.saturation, + args.sharpness, + ) + + elif args.command == "crop": + cli.crop_image( + args.input, args.output, args.x, args.y, args.width, args.height + ) + + elif args.command == "rotate": + cli.rotate_image( + args.input, args.output, args.angle, expand=not args.no_expand + ) + + elif args.command == "flip": cli.flip_image(args.input, args.output, args.direction) - - elif args.command == 'convert': + + elif args.command == "convert": cli.convert_format(args.input, args.output, args.format) - - elif args.command == 'thumbnail': + + elif args.command == "thumbnail": cli.create_thumbnail(args.input, args.output, tuple(args.size)) - - elif args.command == 'watermark': - cli.add_watermark(args.input, args.output, args.text, args.position, args.opacity) - - elif args.command == 'metadata': + + elif args.command == "watermark": + cli.add_watermark( + args.input, args.output, args.text, args.position, args.opacity + ) + + elif args.command == "metadata": cli.extract_metadata(args.input) - - elif args.command == 'collage': + + elif args.command == "collage": cli.create_collage(args.inputs, args.output, args.cols, args.padding) - - elif args.command == 'effect': + + elif args.command == "effect": cli.apply_artistic_effects(args.input, args.output, args.type) - - elif args.command == 'batch': + + elif args.command == "batch": kwargs = {} - if args.operation == 'resize': - kwargs = {'width': args.width, 'height': args.height} - elif args.operation == 'filter': - kwargs = {'filter_type': args.filter_type} - elif args.operation == 'effect': - kwargs = {'effect': args.effect_type} - elif args.operation == 'thumbnail': - kwargs = {'size': tuple(args.size)} - + if args.operation == "resize": + kwargs = {"width": args.width, "height": args.height} + elif args.operation == "filter": + kwargs = {"filter_type": args.filter_type} + elif args.operation == "effect": + kwargs = {"effect": args.effect_type} + elif args.operation == "thumbnail": + kwargs = {"size": tuple(args.size)} + cli.batch_process(args.input_dir, args.output_dir, args.operation, **kwargs) - - elif args.command == 'gui': + + elif args.command == "gui": cli.simple_gui() - + except Exception as e: print(f"Error: {e}") sys.exit(1) + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/pillow-cli/pytest.ini b/pillow-cli/pytest.ini index c30aa14f8..a8c0f7dac 100644 --- a/pillow-cli/pytest.ini +++ b/pillow-cli/pytest.ini @@ -3,7 +3,7 @@ testpaths = . python_files = test_*.py python_classes = Test* python_functions = test_* -addopts = +addopts = -v --tb=short --strict-markers @@ -21,4 +21,4 @@ markers = filterwarnings = ignore::DeprecationWarning - ignore::PendingDeprecationWarning \ No newline at end of file + ignore::PendingDeprecationWarning diff --git a/pillow-cli/requirements.txt b/pillow-cli/requirements.txt index 76860ec75..dc3104f12 100644 --- a/pillow-cli/requirements.txt +++ b/pillow-cli/requirements.txt @@ -8,4 +8,4 @@ pyinstaller>=5.13.0 # Testing dependencies pytest>=7.0.0 pytest-cov>=4.0.0 -pytest-mock>=3.10.0 \ No newline at end of file +pytest-mock>=3.10.0 diff --git a/pillow-cli/tests/conftest.py b/pillow-cli/tests/conftest.py index 865ef1acb..21cbb258b 100644 --- a/pillow-cli/tests/conftest.py +++ b/pillow-cli/tests/conftest.py @@ -2,10 +2,14 @@ Test setup and fixtures for the Pillow CLI project """ -import pytest -import tempfile -import shutil +from __future__ import annotations + import os +import shutil +import tempfile + +import pytest + from PIL import Image, ImageDraw @@ -20,9 +24,12 @@ def test_data_dir(): @pytest.fixture def create_test_image(): """Helper to make test images on demand""" - def _create_image(size=(100, 100), color='red', format='JPEG', mode='RGB', save_path=None): + + def _create_image( + size=(100, 100), color="red", format="JPEG", mode="RGB", save_path=None + ): image = Image.new(mode, size, color=color) - + # Add some visual elements to make it more realistic draw = ImageDraw.Draw(image) if size[0] >= 50 and size[1] >= 50: @@ -30,23 +37,26 @@ def create_test_image(): rect_size = min(size[0] // 2, size[1] // 2) x1, y1 = size[0] // 4, size[1] // 4 x2, y2 = x1 + rect_size, y1 + rect_size - - if mode == 'RGB': - draw.rectangle([x1, y1, x2, y2], fill='blue') - elif mode == 'RGBA': + + if mode == "RGB": + draw.rectangle([x1, y1, x2, y2], fill="blue") + elif mode == "RGBA": draw.rectangle([x1, y1, x2, y2], fill=(0, 0, 255, 200)) - + # Circle around the edge if there's room if size[0] >= 20 and size[1] >= 20: margin = 5 - draw.ellipse([margin, margin, size[0] - margin, size[1] - margin], - outline='green', width=2) - + draw.ellipse( + [margin, margin, size[0] - margin, size[1] - margin], + outline="green", + width=2, + ) + if save_path: image.save(save_path, format) return save_path return image - + return _create_image @@ -54,16 +64,16 @@ def create_test_image(): def sample_images_batch(test_data_dir, create_test_image): """Makes a batch of sample images for testing""" images = [] - colors = ['red', 'green', 'blue', 'yellow', 'purple', 'orange'] + colors = ["red", "green", "blue", "yellow", "purple", "orange"] sizes = [(50, 50), (100, 100), (150, 100), (100, 150)] - + for i, (color, size) in enumerate(zip(colors, sizes * 2)): # cycle through sizes if i >= len(colors): break image_path = os.path.join(test_data_dir, f"batch_image_{i:02d}.jpg") create_test_image(size=size, color=color, save_path=image_path) images.append(image_path) - + return images @@ -71,37 +81,33 @@ def sample_images_batch(test_data_dir, create_test_image): def sample_formats(test_data_dir, create_test_image): """Creates test images in different formats for conversion testing""" formats_data = {} - + # JPEG jpeg_path = os.path.join(test_data_dir, "sample.jpg") - formats_data['jpeg'] = create_test_image(save_path=jpeg_path, format='JPEG') - + formats_data["jpeg"] = create_test_image(save_path=jpeg_path, format="JPEG") + # PNG with transparency png_path = os.path.join(test_data_dir, "sample.png") - formats_data['png'] = create_test_image(save_path=png_path, format='PNG', mode='RGBA') - + formats_data["png"] = create_test_image( + save_path=png_path, format="PNG", mode="RGBA" + ) + # BMP bmp_path = os.path.join(test_data_dir, "sample.bmp") - formats_data['bmp'] = create_test_image(save_path=bmp_path, format='BMP') - + formats_data["bmp"] = create_test_image(save_path=bmp_path, format="BMP") + # GIF gif_path = os.path.join(test_data_dir, "sample.gif") - formats_data['gif'] = create_test_image(save_path=gif_path, format='GIF') - + formats_data["gif"] = create_test_image(save_path=gif_path, format="GIF") + return formats_data def pytest_configure(config): """Add our custom test markers""" - config.addinivalue_line( - "markers", "unit: mark test as a unit test" - ) - config.addinivalue_line( - "markers", "integration: mark test as an integration test" - ) - config.addinivalue_line( - "markers", "slow: mark test as slow running" - ) + config.addinivalue_line("markers", "unit: mark test as a unit test") + config.addinivalue_line("markers", "integration: mark test as an integration test") + config.addinivalue_line("markers", "slow: mark test as slow running") config.addinivalue_line( "markers", "gui: mark test as GUI-related (may require display)" ) @@ -113,15 +119,15 @@ def pytest_collection_modifyitems(config, items): # Unit tests if "TestPillowCLI" in item.nodeid: item.add_marker(pytest.mark.unit) - + # Integration tests elif "TestIntegration" in item.nodeid: item.add_marker(pytest.mark.integration) - + # Slow tests (batch processing, large files, etc.) if "batch_process" in item.name or "large_dimensions" in item.name: item.add_marker(pytest.mark.slow) - + # GUI tests if "gui" in item.name.lower(): - item.add_marker(pytest.mark.gui) \ No newline at end of file + item.add_marker(pytest.mark.gui) diff --git a/pillow-cli/tests/test_pillow_cli.py b/pillow-cli/tests/test_pillow_cli.py index 0aeaa5e91..4c02a4edc 100644 --- a/pillow-cli/tests/test_pillow_cli.py +++ b/pillow-cli/tests/test_pillow_cli.py @@ -3,32 +3,34 @@ Test suite for Pillow CLI tool Comprehensive tests covering all functionality """ +from __future__ import annotations -import pytest -import os -import tempfile -import shutil import json -from pathlib import Path -from PIL import Image, ImageDraw, ImageFont -import numpy as np -from unittest.mock import patch, MagicMock, mock_open +import os +import shutil # Import the CLI class import sys -import os +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from PIL import Image, ImageDraw + sys.path.append(os.path.dirname(os.path.abspath(__file__))) from pillow_cli import PillowCLI class TestPillowCLI: """Test class for PillowCLI functionality""" - + @pytest.fixture def cli(self): """Create a PillowCLI instance for testing""" return PillowCLI() - + @pytest.fixture def temp_dir(self): """Create a temporary directory for test files""" @@ -40,44 +42,45 @@ class TestPillowCLI: except PermissionError: # Try again after a brief delay import time + time.sleep(0.1) try: shutil.rmtree(temp_dir) except PermissionError: pass # Skip cleanup if files are still locked - + @pytest.fixture def sample_image(self, temp_dir): """Create a sample image for testing""" image_path = os.path.join(temp_dir, "sample.jpg") # Create a simple 100x100 RGB image - image = Image.new('RGB', (100, 100), color='red') + image = Image.new("RGB", (100, 100), color="red") # Add some content to make it more realistic draw = ImageDraw.Draw(image) - draw.rectangle([25, 25, 75, 75], fill='blue') - draw.ellipse([10, 10, 90, 90], outline='green', width=3) - image.save(image_path, 'JPEG') + draw.rectangle([25, 25, 75, 75], fill="blue") + draw.ellipse([10, 10, 90, 90], outline="green", width=3) + image.save(image_path, "JPEG") return image_path - + @pytest.fixture def sample_png_image(self, temp_dir): """Create a sample PNG image with transparency""" image_path = os.path.join(temp_dir, "sample.png") - image = Image.new('RGBA', (100, 100), color=(255, 0, 0, 128)) + image = Image.new("RGBA", (100, 100), color=(255, 0, 0, 128)) draw = ImageDraw.Draw(image) draw.rectangle([25, 25, 75, 75], fill=(0, 0, 255, 200)) - image.save(image_path, 'PNG') + image.save(image_path, "PNG") return image_path - + @pytest.fixture def multiple_sample_images(self, temp_dir): """Create multiple sample images for collage testing""" images = [] - colors = ['red', 'green', 'blue', 'yellow'] + colors = ["red", "green", "blue", "yellow"] for i, color in enumerate(colors): image_path = os.path.join(temp_dir, f"sample_{i}.jpg") - image = Image.new('RGB', (50, 50), color=color) - image.save(image_path, 'JPEG') + image = Image.new("RGB", (50, 50), color=color) + image.save(image_path, "JPEG") images.append(image_path) return images @@ -85,405 +88,426 @@ class TestPillowCLI: def test_cli_initialization(self, cli): """Test CLI initialization""" assert isinstance(cli, PillowCLI) - assert hasattr(cli, 'supported_formats') - expected_formats = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.webp'} + assert hasattr(cli, "supported_formats") + expected_formats = {".jpg", ".jpeg", ".png", ".bmp", ".gif", ".tiff", ".webp"} assert cli.supported_formats == expected_formats - + # Test image validation def test_validate_image_path_valid(self, cli, sample_image): """Test validation of valid image path""" assert cli.validate_image_path(sample_image) is True - + def test_validate_image_path_nonexistent(self, cli): """Test validation of non-existent image path""" with pytest.raises(FileNotFoundError, match="Image file not found"): cli.validate_image_path("nonexistent.jpg") - + def test_validate_image_path_unsupported_format(self, cli, temp_dir): """Test validation of unsupported image format""" unsupported_file = os.path.join(temp_dir, "test.txt") - with open(unsupported_file, 'w') as f: + with open(unsupported_file, "w") as f: f.write("test") - + with pytest.raises(ValueError, match="Unsupported image format"): cli.validate_image_path(unsupported_file) - + # Test image loading def test_load_image(self, cli, sample_image): """Test image loading""" image = cli.load_image(sample_image) assert isinstance(image, Image.Image) assert image.size == (100, 100) - assert image.mode in ['RGB', 'RGBA'] - + assert image.mode in ["RGB", "RGBA"] + # Test image saving def test_save_image_jpg(self, cli, temp_dir): """Test saving image as JPEG""" - image = Image.new('RGB', (50, 50), color='red') + image = Image.new("RGB", (50, 50), color="red") output_path = os.path.join(temp_dir, "output.jpg") - - with patch('builtins.print') as mock_print: + + with patch("builtins.print") as mock_print: cli.save_image(image, output_path) - + assert os.path.exists(output_path) saved_image = Image.open(output_path) assert saved_image.size == (50, 50) mock_print.assert_called_with(f"Image saved to: {output_path}") - + def test_save_image_rgba_to_jpg(self, cli, temp_dir): """Test saving RGBA image as JPEG (should convert to RGB)""" - image = Image.new('RGBA', (50, 50), color=(255, 0, 0, 128)) + image = Image.new("RGBA", (50, 50), color=(255, 0, 0, 128)) output_path = os.path.join(temp_dir, "output.jpg") - + cli.save_image(image, output_path) - + assert os.path.exists(output_path) saved_image = Image.open(output_path) - assert saved_image.mode == 'RGB' - + assert saved_image.mode == "RGB" + def test_save_image_create_directory(self, cli, temp_dir): """Test saving image creates output directory if it doesn't exist""" - image = Image.new('RGB', (50, 50), color='red') + image = Image.new("RGB", (50, 50), color="red") output_path = os.path.join(temp_dir, "subdir", "output.jpg") - + cli.save_image(image, output_path) - + assert os.path.exists(output_path) - + # Test resize functionality def test_resize_image_both_dimensions(self, cli, sample_image, temp_dir): """Test resizing with both width and height specified""" output_path = os.path.join(temp_dir, "resized.jpg") - - with patch('builtins.print') as mock_print: + + with patch("builtins.print") as mock_print: cli.resize_image(sample_image, output_path, width=200, height=150) - + assert os.path.exists(output_path) resized_image = Image.open(output_path) # Should maintain aspect ratio by default assert resized_image.size[0] <= 200 assert resized_image.size[1] <= 150 mock_print.assert_any_call("Resized from 100x100 to 150x150") - + def test_resize_image_width_only(self, cli, sample_image, temp_dir): """Test resizing with width only""" output_path = os.path.join(temp_dir, "resized.jpg") - + cli.resize_image(sample_image, output_path, width=200) - + resized_image = Image.open(output_path) assert resized_image.size == (200, 200) # Square image - + def test_resize_image_height_only(self, cli, sample_image, temp_dir): """Test resizing with height only""" output_path = os.path.join(temp_dir, "resized.jpg") - + cli.resize_image(sample_image, output_path, height=150) - + resized_image = Image.open(output_path) assert resized_image.size == (150, 150) # Square image - + def test_resize_image_no_dimensions(self, cli, sample_image, temp_dir): """Test resizing with no dimensions specified""" output_path = os.path.join(temp_dir, "resized.jpg") - + with pytest.raises(ValueError, match="Must specify at least width or height"): cli.resize_image(sample_image, output_path) - - def test_resize_image_no_aspect_ratio_maintenance(self, cli, sample_image, temp_dir): + + def test_resize_image_no_aspect_ratio_maintenance( + self, cli, sample_image, temp_dir + ): """Test resizing without maintaining aspect ratio""" output_path = os.path.join(temp_dir, "resized.jpg") - - cli.resize_image(sample_image, output_path, width=200, height=100, maintain_aspect=False) - + + cli.resize_image( + sample_image, output_path, width=200, height=100, maintain_aspect=False + ) + resized_image = Image.open(output_path) assert resized_image.size == (200, 100) - + # Test filter functionality def test_apply_filters_blur(self, cli, sample_image, temp_dir): """Test applying blur filter""" output_path = os.path.join(temp_dir, "blurred.jpg") - - with patch('builtins.print') as mock_print: - cli.apply_filters(sample_image, output_path, 'blur') - + + with patch("builtins.print") as mock_print: + cli.apply_filters(sample_image, output_path, "blur") + assert os.path.exists(output_path) mock_print.assert_any_call("Applied blur filter") - + def test_apply_filters_all_types(self, cli, sample_image, temp_dir): """Test all available filter types""" - filter_types = ['blur', 'contour', 'detail', 'edge_enhance', 'sharpen', - 'smooth', 'gaussian_blur', 'unsharp_mask'] - + filter_types = [ + "blur", + "contour", + "detail", + "edge_enhance", + "sharpen", + "smooth", + "gaussian_blur", + "unsharp_mask", + ] + for filter_type in filter_types: output_path = os.path.join(temp_dir, f"{filter_type}.jpg") cli.apply_filters(sample_image, output_path, filter_type) assert os.path.exists(output_path) - + def test_apply_filters_invalid_type(self, cli, sample_image, temp_dir): """Test applying invalid filter type""" output_path = os.path.join(temp_dir, "filtered.jpg") - + with pytest.raises(ValueError, match="Unknown filter"): - cli.apply_filters(sample_image, output_path, 'invalid_filter') - + cli.apply_filters(sample_image, output_path, "invalid_filter") + # Test image adjustment def test_adjust_image_all_parameters(self, cli, sample_image, temp_dir): """Test adjusting all image parameters""" output_path = os.path.join(temp_dir, "adjusted.jpg") - - with patch('builtins.print') as mock_print: - cli.adjust_image(sample_image, output_path, - brightness=1.2, contrast=1.1, - saturation=0.9, sharpness=1.3) - + + with patch("builtins.print") as mock_print: + cli.adjust_image( + sample_image, + output_path, + brightness=1.2, + contrast=1.1, + saturation=0.9, + sharpness=1.3, + ) + assert os.path.exists(output_path) - mock_print.assert_any_call("Adjusted image - Brightness: 1.2, Contrast: 1.1, Saturation: 0.9, Sharpness: 1.3") - + mock_print.assert_any_call( + "Adjusted image - Brightness: 1.2, Contrast: 1.1, Saturation: 0.9, Sharpness: 1.3" + ) + def test_adjust_image_single_parameter(self, cli, sample_image, temp_dir): """Test adjusting single parameter""" output_path = os.path.join(temp_dir, "adjusted.jpg") - + cli.adjust_image(sample_image, output_path, brightness=1.5) - + assert os.path.exists(output_path) - + # Test cropping def test_crop_image_valid(self, cli, sample_image, temp_dir): """Test valid image cropping""" output_path = os.path.join(temp_dir, "cropped.jpg") - - with patch('builtins.print') as mock_print: + + with patch("builtins.print") as mock_print: cli.crop_image(sample_image, output_path, 10, 10, 50, 50) - + assert os.path.exists(output_path) cropped_image = Image.open(output_path) assert cropped_image.size == (50, 50) mock_print.assert_any_call("Cropped to 50x50 from position (10, 10)") - + def test_crop_image_invalid_dimensions(self, cli, sample_image, temp_dir): """Test cropping with invalid dimensions""" output_path = os.path.join(temp_dir, "cropped.jpg") - + with pytest.raises(ValueError, match="Crop dimensions exceed image size"): cli.crop_image(sample_image, output_path, 50, 50, 100, 100) - + # Test rotation def test_rotate_image(self, cli, sample_image, temp_dir): """Test image rotation""" output_path = os.path.join(temp_dir, "rotated.jpg") - - with patch('builtins.print') as mock_print: + + with patch("builtins.print") as mock_print: cli.rotate_image(sample_image, output_path, 45) - + assert os.path.exists(output_path) mock_print.assert_any_call("Rotated image by 45 degrees") - + def test_rotate_image_no_expand(self, cli, sample_image, temp_dir): """Test image rotation without expanding canvas""" output_path = os.path.join(temp_dir, "rotated.jpg") - + cli.rotate_image(sample_image, output_path, 45, expand=False) - + assert os.path.exists(output_path) rotated_image = Image.open(output_path) assert rotated_image.size == (100, 100) # Original size maintained - + # Test flipping def test_flip_image_horizontal(self, cli, sample_image, temp_dir): """Test horizontal image flip""" output_path = os.path.join(temp_dir, "flipped.jpg") - - with patch('builtins.print') as mock_print: - cli.flip_image(sample_image, output_path, 'horizontal') - + + with patch("builtins.print") as mock_print: + cli.flip_image(sample_image, output_path, "horizontal") + assert os.path.exists(output_path) mock_print.assert_any_call("Flipped image horizontally") - + def test_flip_image_vertical(self, cli, sample_image, temp_dir): """Test vertical image flip""" output_path = os.path.join(temp_dir, "flipped.jpg") - - with patch('builtins.print') as mock_print: - cli.flip_image(sample_image, output_path, 'vertical') - + + with patch("builtins.print") as mock_print: + cli.flip_image(sample_image, output_path, "vertical") + assert os.path.exists(output_path) mock_print.assert_any_call("Flipped image vertically") - + def test_flip_image_invalid_direction(self, cli, sample_image, temp_dir): """Test flipping with invalid direction""" output_path = os.path.join(temp_dir, "flipped.jpg") - - with pytest.raises(ValueError, match="Direction must be 'horizontal' or 'vertical'"): - cli.flip_image(sample_image, output_path, 'diagonal') - + + with pytest.raises( + ValueError, match="Direction must be 'horizontal' or 'vertical'" + ): + cli.flip_image(sample_image, output_path, "diagonal") + # Test format conversion def test_convert_format_png(self, cli, sample_image, temp_dir): """Test converting to PNG format""" output_path = os.path.join(temp_dir, "converted.png") - - with patch('builtins.print') as mock_print: - cli.convert_format(sample_image, output_path, 'PNG') - + + with patch("builtins.print") as mock_print: + cli.convert_format(sample_image, output_path, "PNG") + assert os.path.exists(output_path) converted_image = Image.open(output_path) - assert converted_image.format == 'PNG' + assert converted_image.format == "PNG" mock_print.assert_any_call("Converted to PNG format") - + def test_convert_rgba_to_jpeg(self, cli, sample_png_image, temp_dir): """Test converting RGBA image to JPEG""" output_path = os.path.join(temp_dir, "converted.jpg") - - cli.convert_format(sample_png_image, output_path, 'JPEG') - + + cli.convert_format(sample_png_image, output_path, "JPEG") + assert os.path.exists(output_path) converted_image = Image.open(output_path) - assert converted_image.format == 'JPEG' - assert converted_image.mode == 'RGB' - + assert converted_image.format == "JPEG" + assert converted_image.mode == "RGB" + # Test thumbnail creation def test_create_thumbnail(self, cli, sample_image, temp_dir): """Test thumbnail creation""" output_path = os.path.join(temp_dir, "thumb.jpg") - - with patch('builtins.print') as mock_print: + + with patch("builtins.print") as mock_print: cli.create_thumbnail(sample_image, output_path, (64, 64)) - + assert os.path.exists(output_path) thumb_image = Image.open(output_path) assert thumb_image.size[0] <= 64 assert thumb_image.size[1] <= 64 mock_print.assert_any_call("Created thumbnail with size (64, 64)") - + # Test watermark def test_add_watermark(self, cli, sample_image, temp_dir): """Test adding watermark""" output_path = os.path.join(temp_dir, "watermarked.jpg") - - with patch('builtins.print') as mock_print: + + with patch("builtins.print") as mock_print: cli.add_watermark(sample_image, output_path, "Test Watermark") - + assert os.path.exists(output_path) mock_print.assert_any_call("Added watermark 'Test Watermark' at bottom-right") - + def test_add_watermark_all_positions(self, cli, sample_image, temp_dir): """Test watermark at all positions""" - positions = ['top-left', 'top-right', 'bottom-left', 'bottom-right', 'center'] - + positions = ["top-left", "top-right", "bottom-left", "bottom-right", "center"] + for position in positions: output_path = os.path.join(temp_dir, f"watermark_{position}.jpg") cli.add_watermark(sample_image, output_path, "Test", position=position) assert os.path.exists(output_path) - + def test_add_watermark_invalid_position(self, cli, sample_image, temp_dir): """Test watermark with invalid position""" output_path = os.path.join(temp_dir, "watermarked.jpg") - + with pytest.raises(ValueError, match="Invalid position"): - cli.add_watermark(sample_image, output_path, "Test", position='invalid') - + cli.add_watermark(sample_image, output_path, "Test", position="invalid") + # Test metadata extraction def test_extract_metadata(self, cli, sample_image): """Test metadata extraction""" - with patch('builtins.print') as mock_print: + with patch("builtins.print") as mock_print: metadata = cli.extract_metadata(sample_image) - + assert isinstance(metadata, dict) - assert 'filename' in metadata - assert 'format' in metadata - assert 'size' in metadata - assert 'width' in metadata - assert 'height' in metadata - assert metadata['width'] == 100 - assert metadata['height'] == 100 - + assert "filename" in metadata + assert "format" in metadata + assert "size" in metadata + assert "width" in metadata + assert "height" in metadata + assert metadata["width"] == 100 + assert metadata["height"] == 100 + # Check that JSON was printed mock_print.assert_called() - + # Test collage creation def test_create_collage(self, cli, multiple_sample_images, temp_dir): """Test collage creation""" output_path = os.path.join(temp_dir, "collage.jpg") - - with patch('builtins.print') as mock_print: + + with patch("builtins.print") as mock_print: cli.create_collage(multiple_sample_images, output_path, cols=2, padding=5) - + assert os.path.exists(output_path) collage_image = Image.open(output_path) assert collage_image.size[0] > 50 # Should be larger than individual images assert collage_image.size[1] > 50 mock_print.assert_any_call("Created collage with 4 images (2x2 grid)") - + def test_create_collage_empty_list(self, cli, temp_dir): """Test collage with empty image list""" output_path = os.path.join(temp_dir, "collage.jpg") - + with pytest.raises(ValueError, match="No input images provided"): cli.create_collage([], output_path) - + # Test artistic effects def test_apply_artistic_effects_sepia(self, cli, sample_image, temp_dir): """Test sepia effect""" output_path = os.path.join(temp_dir, "sepia.jpg") - - with patch('builtins.print') as mock_print: - cli.apply_artistic_effects(sample_image, output_path, 'sepia') - + + with patch("builtins.print") as mock_print: + cli.apply_artistic_effects(sample_image, output_path, "sepia") + assert os.path.exists(output_path) mock_print.assert_any_call("Applied sepia effect") - + def test_apply_artistic_effects_all_types(self, cli, sample_image, temp_dir): """Test all artistic effects""" - effects = ['sepia', 'grayscale', 'invert', 'posterize', 'solarize'] - + effects = ["sepia", "grayscale", "invert", "posterize", "solarize"] + for effect in effects: output_path = os.path.join(temp_dir, f"{effect}.jpg") cli.apply_artistic_effects(sample_image, output_path, effect) assert os.path.exists(output_path) - + def test_apply_artistic_effects_invalid(self, cli, sample_image, temp_dir): """Test invalid artistic effect""" output_path = os.path.join(temp_dir, "effect.jpg") - + with pytest.raises(ValueError, match="Unknown effect"): - cli.apply_artistic_effects(sample_image, output_path, 'invalid_effect') - + cli.apply_artistic_effects(sample_image, output_path, "invalid_effect") + # Test histogram analysis def test_analyze_histogram(self, cli, sample_image, temp_dir): """Test histogram analysis""" - with patch('builtins.print') as mock_print: + with patch("builtins.print") as mock_print: histogram_data = cli.analyze_histogram(sample_image) - + assert isinstance(histogram_data, dict) - assert 'red_channel' in histogram_data - assert 'green_channel' in histogram_data - assert 'blue_channel' in histogram_data - assert 'total_pixels' in histogram_data - assert histogram_data['total_pixels'] == 10000 # 100x100 - + assert "red_channel" in histogram_data + assert "green_channel" in histogram_data + assert "blue_channel" in histogram_data + assert "total_pixels" in histogram_data + assert histogram_data["total_pixels"] == 10000 # 100x100 + mock_print.assert_called() - + def test_analyze_histogram_with_output(self, cli, sample_image, temp_dir): """Test histogram analysis with output file""" output_path = os.path.join(temp_dir, "histogram.json") - - with patch('builtins.print') as mock_print: + + with patch("builtins.print") as mock_print: cli.analyze_histogram(sample_image, output_path) - + assert os.path.exists(output_path) - with open(output_path, 'r') as f: + with open(output_path) as f: histogram_data = json.load(f) - + assert isinstance(histogram_data, dict) - assert 'total_pixels' in histogram_data + assert "total_pixels" in histogram_data mock_print.assert_any_call(f"Histogram data saved to: {output_path}") - + # Test color palette extraction def test_extract_color_palette(self, cli, sample_image): """Test color palette extraction""" - with patch('builtins.print') as mock_print: + with patch("builtins.print") as mock_print: colors = cli.extract_color_palette(sample_image, num_colors=3) - + assert isinstance(colors, list) assert len(colors) <= 3 for color in colors: @@ -491,39 +515,48 @@ class TestPillowCLI: assert len(color) == 3 # RGB values for value in color: assert 0 <= value <= 255 - + mock_print.assert_called() - + def test_extract_color_palette_without_sklearn(self, cli, sample_image): """Test color palette extraction without sklearn (fallback method)""" - with patch('sklearn.cluster.KMeans', side_effect=ImportError()): - with patch('builtins.print') as mock_print: + with patch("sklearn.cluster.KMeans", side_effect=ImportError()): + with patch("builtins.print") as mock_print: colors = cli.extract_color_palette(sample_image, num_colors=3) - + assert isinstance(colors, list) - mock_print.assert_any_call("scikit-learn not installed. Using simple color extraction...") - + mock_print.assert_any_call( + "scikit-learn not installed. Using simple color extraction..." + ) + # Test border creation def test_create_border(self, cli, sample_image, temp_dir): """Test border creation""" output_path = os.path.join(temp_dir, "bordered.jpg") - - with patch('builtins.print') as mock_print: - cli.create_border(sample_image, output_path, border_width=5, border_color='red') - + + with patch("builtins.print") as mock_print: + cli.create_border( + sample_image, output_path, border_width=5, border_color="red" + ) + assert os.path.exists(output_path) bordered_image = Image.open(output_path) - assert bordered_image.size == (110, 110) # Original 100x100 + 5px border on each side + assert bordered_image.size == ( + 110, + 110, + ) # Original 100x100 + 5px border on each side mock_print.assert_any_call("Added 5px border with color (255, 0, 0)") - + def test_create_border_hex_color(self, cli, sample_image, temp_dir): """Test border with hex color""" output_path = os.path.join(temp_dir, "bordered.jpg") - - cli.create_border(sample_image, output_path, border_width=3, border_color='#FF0000') - + + cli.create_border( + sample_image, output_path, border_width=3, border_color="#FF0000" + ) + assert os.path.exists(output_path) - + # Test composite creation def test_create_composite(self, cli, temp_dir): """Test composite image creation""" @@ -531,71 +564,81 @@ class TestPillowCLI: bg_path = os.path.join(temp_dir, "background.png") overlay_path = os.path.join(temp_dir, "overlay.png") output_path = os.path.join(temp_dir, "composite.png") - - bg_image = Image.new('RGBA', (100, 100), color=(255, 0, 0, 255)) - overlay_image = Image.new('RGBA', (50, 50), color=(0, 0, 255, 128)) - - bg_image.save(bg_path, 'PNG') - overlay_image.save(overlay_path, 'PNG') - - with patch('builtins.print') as mock_print: - cli.create_composite(bg_path, overlay_path, output_path, position=(25, 25), opacity=0.8) - + + bg_image = Image.new("RGBA", (100, 100), color=(255, 0, 0, 255)) + overlay_image = Image.new("RGBA", (50, 50), color=(0, 0, 255, 128)) + + bg_image.save(bg_path, "PNG") + overlay_image.save(overlay_path, "PNG") + + with patch("builtins.print") as mock_print: + cli.create_composite( + bg_path, overlay_path, output_path, position=(25, 25), opacity=0.8 + ) + assert os.path.exists(output_path) - mock_print.assert_any_call("Created composite image at position (25, 25) with opacity 0.8") - + mock_print.assert_any_call( + "Created composite image at position (25, 25) with opacity 0.8" + ) + # Test vignette effect def test_apply_vignette(self, cli, sample_image, temp_dir): """Test vignette effect""" output_path = os.path.join(temp_dir, "vignette.jpg") - - with patch('builtins.print') as mock_print: + + with patch("builtins.print") as mock_print: cli.apply_vignette(sample_image, output_path, strength=0.3) - + assert os.path.exists(output_path) mock_print.assert_any_call("Applied vignette effect with strength 0.3") - + # Test contact sheet creation def test_create_contact_sheet(self, cli, multiple_sample_images, temp_dir): """Test contact sheet creation""" output_path = os.path.join(temp_dir, "contact_sheet.jpg") - - with patch('builtins.print') as mock_print: - cli.create_contact_sheet(multiple_sample_images, output_path, sheet_width=800, margin=5) - + + with patch("builtins.print") as mock_print: + cli.create_contact_sheet( + multiple_sample_images, output_path, sheet_width=800, margin=5 + ) + assert os.path.exists(output_path) contact_image = Image.open(output_path) assert contact_image.size[0] == 800 # Fix: The actual implementation creates a 1x4 grid for 4 images, not 2x2 mock_print.assert_any_call("Created contact sheet with 4 images (1x4 grid)") - + def test_create_contact_sheet_empty_list(self, cli, temp_dir): """Test contact sheet with empty image list""" output_path = os.path.join(temp_dir, "contact_sheet.jpg") - + with pytest.raises(ValueError, match="No input images provided"): cli.create_contact_sheet([], output_path) - + # Test channel splitting def test_split_channels(self, cli, sample_image, temp_dir): """Test RGB channel splitting""" output_dir = os.path.join(temp_dir, "channels") - - with patch('builtins.print') as mock_print: + + with patch("builtins.print") as mock_print: channel_paths = cli.split_channels(sample_image, output_dir) - + assert len(channel_paths) == 3 for path in channel_paths: assert os.path.exists(path) - + # Check that files have correct names base_name = Path(sample_image).stem - expected_files = [f"{base_name}_red.png", f"{base_name}_green.png", f"{base_name}_blue.png"] + expected_files = [ + f"{base_name}_red.png", + f"{base_name}_green.png", + f"{base_name}_blue.png", + ] for expected_file in expected_files: assert any(expected_file in path for path in channel_paths) - + mock_print.assert_any_call(f"Split channels saved to {output_dir}") - + # Test batch processing def test_batch_process_resize(self, cli, temp_dir): """Test batch processing with resize operation""" @@ -603,83 +646,87 @@ class TestPillowCLI: input_dir = os.path.join(temp_dir, "input") output_dir = os.path.join(temp_dir, "output") os.makedirs(input_dir) - + # Create sample images for i in range(3): image_path = os.path.join(input_dir, f"image_{i}.jpg") - image = Image.new('RGB', (100, 100), color='red') - image.save(image_path, 'JPEG') - - with patch('builtins.print') as mock_print: - cli.batch_process(input_dir, output_dir, 'resize', width=50, height=50) - + image = Image.new("RGB", (100, 100), color="red") + image.save(image_path, "JPEG") + + with patch("builtins.print") as mock_print: + cli.batch_process(input_dir, output_dir, "resize", width=50, height=50) + # Check output directory exists and contains processed images assert os.path.exists(output_dir) output_files = os.listdir(output_dir) assert len(output_files) == 3 - + # Fix: The actual implementation finds both .jpg and .JPG files, so it finds 6 files total # Let's check for the correct number that the implementation actually finds - mock_print.assert_any_call("Batch processing complete! Output saved to: " + output_dir) - + mock_print.assert_any_call( + "Batch processing complete! Output saved to: " + output_dir + ) + def test_batch_process_no_images(self, cli, temp_dir): """Test batch processing with no images in directory""" input_dir = os.path.join(temp_dir, "empty") output_dir = os.path.join(temp_dir, "output") os.makedirs(input_dir) - - with patch('builtins.print') as mock_print: - cli.batch_process(input_dir, output_dir, 'resize') - + + with patch("builtins.print") as mock_print: + cli.batch_process(input_dir, output_dir, "resize") + mock_print.assert_any_call("No image files found in the input directory") - + def test_batch_process_nonexistent_directory(self, cli, temp_dir): """Test batch processing with non-existent input directory""" input_dir = os.path.join(temp_dir, "nonexistent") output_dir = os.path.join(temp_dir, "output") - + with pytest.raises(FileNotFoundError, match="Input directory not found"): - cli.batch_process(input_dir, output_dir, 'resize') - + cli.batch_process(input_dir, output_dir, "resize") + def test_batch_process_unknown_operation(self, cli, temp_dir): """Test batch processing with unknown operation""" input_dir = os.path.join(temp_dir, "input") output_dir = os.path.join(temp_dir, "output") os.makedirs(input_dir) - + # Create a sample image image_path = os.path.join(input_dir, "image.jpg") - image = Image.new('RGB', (100, 100), color='red') - image.save(image_path, 'JPEG') - - with patch('builtins.print') as mock_print: - cli.batch_process(input_dir, output_dir, 'unknown_operation') - + image = Image.new("RGB", (100, 100), color="red") + image.save(image_path, "JPEG") + + with patch("builtins.print") as mock_print: + cli.batch_process(input_dir, output_dir, "unknown_operation") + mock_print.assert_any_call("Unknown batch operation: unknown_operation") - + # Test GUI functionality def test_simple_gui_not_available(self, cli): """Test GUI when tkinter is not available""" - with patch('pillow_cli.GUI_AVAILABLE', False): - with patch('builtins.print') as mock_print: + with patch("pillow_cli.GUI_AVAILABLE", False): + with patch("builtins.print") as mock_print: cli.simple_gui() - - mock_print.assert_called_with("GUI not available. Install tkinter to use this feature.") - - @patch('pillow_cli.GUI_AVAILABLE', True) - @patch('pillow_cli.tk') + + mock_print.assert_called_with( + "GUI not available. Install tkinter to use this feature." + ) + + @patch("pillow_cli.GUI_AVAILABLE", True) + @patch("pillow_cli.tk") def test_simple_gui_available(self, mock_tk_module, cli): """Test GUI when tkinter is available""" # Mock the entire tk module and its components mock_root = MagicMock() mock_tk_module.Tk.return_value = mock_root mock_tk_module.StringVar = MagicMock - + # Mock ttk as well mock_ttk = MagicMock() - with patch('pillow_cli.ttk', mock_ttk): + with patch("pillow_cli.ttk", mock_ttk): cli.simple_gui() - + mock_tk_module.Tk.assert_called_once() mock_root.title.assert_called_with("Pillow CLI - Simple GUI") mock_root.geometry.assert_called_with("600x400") @@ -688,7 +735,7 @@ class TestPillowCLI: class TestIntegration: """Integration tests for the CLI tool""" - + @pytest.fixture def temp_dir(self): """Create a temporary directory for test files""" @@ -698,63 +745,63 @@ class TestIntegration: shutil.rmtree(temp_dir) except PermissionError: pass # Skip cleanup if files are locked - + @pytest.fixture def sample_image(self, temp_dir): """Create a sample image for integration testing""" image_path = os.path.join(temp_dir, "sample.jpg") - image = Image.new('RGB', (200, 200), color='blue') + image = Image.new("RGB", (200, 200), color="blue") draw = ImageDraw.Draw(image) - draw.rectangle([50, 50, 150, 150], fill='yellow') - image.save(image_path, 'JPEG') + draw.rectangle([50, 50, 150, 150], fill="yellow") + image.save(image_path, "JPEG") return image_path - + def test_image_processing_chain(self, temp_dir, sample_image): """Test chaining multiple image processing operations""" cli = PillowCLI() - + # Step 1: Resize resized_path = os.path.join(temp_dir, "step1_resized.jpg") cli.resize_image(sample_image, resized_path, width=150, height=150) - + # Step 2: Apply filter filtered_path = os.path.join(temp_dir, "step2_filtered.jpg") - cli.apply_filters(resized_path, filtered_path, 'sharpen') - + cli.apply_filters(resized_path, filtered_path, "sharpen") + # Step 3: Add watermark final_path = os.path.join(temp_dir, "step3_final.jpg") - cli.add_watermark(filtered_path, final_path, "Processed", position='center') - + cli.add_watermark(filtered_path, final_path, "Processed", position="center") + assert os.path.exists(final_path) final_image = Image.open(final_path) assert final_image.size == (150, 150) - + def test_format_conversion_chain(self, temp_dir, sample_image): """Test converting between different formats""" cli = PillowCLI() - + # JPG to PNG png_path = os.path.join(temp_dir, "converted.png") - cli.convert_format(sample_image, png_path, 'PNG') - + cli.convert_format(sample_image, png_path, "PNG") + # PNG to BMP bmp_path = os.path.join(temp_dir, "converted.bmp") - cli.convert_format(png_path, bmp_path, 'BMP') - + cli.convert_format(png_path, bmp_path, "BMP") + assert os.path.exists(png_path) assert os.path.exists(bmp_path) - + png_image = Image.open(png_path) bmp_image = Image.open(bmp_path) - - assert png_image.format == 'PNG' - assert bmp_image.format == 'BMP' + + assert png_image.format == "PNG" + assert bmp_image.format == "BMP" assert png_image.size == bmp_image.size class TestErrorHandling: """Test error handling and edge cases""" - + @pytest.fixture def temp_dir(self): """Create a temporary directory for test files""" @@ -764,49 +811,49 @@ class TestErrorHandling: shutil.rmtree(temp_dir) except PermissionError: pass # Skip cleanup if files are locked - + def test_corrupted_image_handling(self, temp_dir): """Test handling of corrupted image files""" cli = PillowCLI() - + # Create a file with wrong extension corrupted_path = os.path.join(temp_dir, "corrupted.jpg") - with open(corrupted_path, 'w') as f: + with open(corrupted_path, "w") as f: f.write("This is not an image file") - + with pytest.raises(Exception): # PIL will raise an exception cli.load_image(corrupted_path) - + def test_very_large_dimensions(self, temp_dir): """Test handling of very large dimensions""" cli = PillowCLI() - + # Create a small image image_path = os.path.join(temp_dir, "small.jpg") - image = Image.new('RGB', (10, 10), color='red') - image.save(image_path, 'JPEG') - + image = Image.new("RGB", (10, 10), color="red") + image.save(image_path, "JPEG") + output_path = os.path.join(temp_dir, "large.jpg") - + # This should work but might be slow for very large sizes # Using moderate size for testing cli.resize_image(image_path, output_path, width=1000, height=1000) - + large_image = Image.open(output_path) assert large_image.size == (1000, 1000) - + def test_zero_dimensions(self, temp_dir): """Test handling of zero dimensions in crop""" cli = PillowCLI() - + image_path = os.path.join(temp_dir, "test.jpg") - image = Image.new('RGB', (100, 100), color='red') - image.save(image_path, 'JPEG') - + image = Image.new("RGB", (100, 100), color="red") + image.save(image_path, "JPEG") + output_path = os.path.join(temp_dir, "cropped.jpg") - + # This should create a very small crop cli.crop_image(image_path, output_path, 0, 0, 1, 1) - + cropped_image = Image.open(output_path) - assert cropped_image.size == (1, 1) \ No newline at end of file + assert cropped_image.size == (1, 1)