llm-imageops 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
imageops/__init__.py ADDED
@@ -0,0 +1,98 @@
1
+ """ImageOps: Production-grade image preprocessing for LLMs."""
2
+
3
+ from .config import ImageOpsConfig
4
+ from .core import ImageProcessor, ProcessingResult, ImageOutput
5
+ from .exceptions import *
6
+ from .providers import get_provider, register_provider
7
+
8
+ __version__ = "1.0.0"
9
+
10
+ # Simple API
11
+ async def process(
12
+ image_source: str,
13
+ provider: str = "anthropic",
14
+ output_format: str = "base64",
15
+ progress_callback=None,
16
+ **kwargs
17
+ ):
18
+ """
19
+ Simple one-line API to process images.
20
+
21
+ Args:
22
+ image_source: File path or URL
23
+ provider: Provider name (default: "anthropic")
24
+ output_format: "base64" or "file" (default: "base64")
25
+ progress_callback: Optional async progress callback
26
+ **kwargs: Additional arguments
27
+
28
+ Returns:
29
+ ProcessingResult object
30
+
31
+ Example:
32
+ >>> result = await imageops.process("image.jpg", provider="anthropic")
33
+ >>> print(result.images[0].data[:50]) # First 50 chars of base64
34
+ """
35
+ async with ImageProcessor(provider=provider) as processor:
36
+ return await processor.process(
37
+ image_source,
38
+ output_format=output_format,
39
+ progress_callback=progress_callback,
40
+ **kwargs
41
+ )
42
+
43
+
44
+ async def process_batch(
45
+ image_sources: list,
46
+ provider: str = "anthropic",
47
+ output_format: str = "base64",
48
+ **kwargs
49
+ ):
50
+ """
51
+ Process multiple images concurrently.
52
+
53
+ Args:
54
+ image_sources: List of file paths or URLs
55
+ provider: Provider name (default: "anthropic")
56
+ output_format: "base64" or "file" (default: "base64")
57
+ **kwargs: Additional arguments
58
+
59
+ Returns:
60
+ List of ProcessingResult objects
61
+
62
+ Example:
63
+ >>> results = await imageops.process_batch(
64
+ ... ["img1.jpg", "img2.jpg"],
65
+ ... provider="anthropic"
66
+ ... )
67
+ """
68
+ async with ImageProcessor(provider=provider) as processor:
69
+ return await processor.process_batch(
70
+ image_sources,
71
+ output_format=output_format,
72
+ **kwargs
73
+ )
74
+
75
+
76
+ __all__ = [
77
+ "ImageOpsConfig",
78
+ "ImageProcessor",
79
+ "ProcessingResult",
80
+ "ImageOutput",
81
+ "process",
82
+ "process_batch",
83
+ "get_provider",
84
+ "register_provider",
85
+ # Exceptions
86
+ "ImageOpsError",
87
+ "ImageDownloadError",
88
+ "ImageValidationError",
89
+ "ImageProcessingError",
90
+ "ImageCorruptedError",
91
+ "ImageTooLargeError",
92
+ "UnsupportedFormatError",
93
+ "CompressionFailedError",
94
+ "ProviderNotFoundError",
95
+ "TimeoutError",
96
+ "ResourceLimitError",
97
+ ]
98
+
imageops/config.py ADDED
@@ -0,0 +1,135 @@
1
+ """Configuration module with resource limits and security settings."""
2
+
3
+ from typing import List, Optional
4
+ from pydantic import Field, validator
5
+
6
+ try:
7
+ from pydantic_settings import BaseSettings
8
+ except ImportError:
9
+ try:
10
+ from pydantic import BaseSettings
11
+ except ImportError:
12
+ raise ImportError(
13
+ "pydantic-settings is required. Install with: pip install pydantic-settings"
14
+ )
15
+
16
+
17
+ class ImageOpsConfig(BaseSettings):
18
+ """Global configuration with resource limits and security."""
19
+
20
+ # Resource Limits
21
+ max_image_size_mb: float = Field(
22
+ 100.0,
23
+ gt=0,
24
+ description="Maximum input image size in MB"
25
+ )
26
+ max_dimension: int = Field(
27
+ 100000,
28
+ gt=0,
29
+ description="Maximum width or height in pixels"
30
+ )
31
+ max_batch_size: int = Field(
32
+ 50,
33
+ ge=1,
34
+ le=1000,
35
+ description="Maximum number of images in batch"
36
+ )
37
+
38
+ # Temp Management
39
+ temp_dir_pattern: str = Field(
40
+ "imageops_{thread_id}",
41
+ description="Pattern for temp directory naming"
42
+ )
43
+ auto_cleanup: bool = Field(
44
+ True,
45
+ description="Auto-cleanup temp files"
46
+ )
47
+
48
+ # Performance
49
+ max_concurrent_ops: int = Field(
50
+ 5,
51
+ ge=1,
52
+ le=20,
53
+ description="Max concurrent operations"
54
+ )
55
+ download_timeout: float = Field(
56
+ 10.0,
57
+ gt=0,
58
+ description="Download timeout in seconds"
59
+ )
60
+ processing_timeout: float = Field(
61
+ 300.0,
62
+ gt=0,
63
+ description="Max processing time per image in seconds"
64
+ )
65
+
66
+ # Retry Logic
67
+ max_download_retries: int = Field(
68
+ 3,
69
+ ge=0,
70
+ le=10,
71
+ description="Max download retries"
72
+ )
73
+ retry_backoff_factor: float = Field(
74
+ 2.0,
75
+ ge=1.0,
76
+ description="Exponential backoff factor"
77
+ )
78
+
79
+ # Compression
80
+ default_quality: int = Field(
81
+ 90,
82
+ ge=1,
83
+ le=100,
84
+ description="Default JPEG quality"
85
+ )
86
+ quality_step: int = Field(
87
+ 5,
88
+ ge=1,
89
+ le=20,
90
+ description="Quality reduction step"
91
+ )
92
+ min_quality: int = Field(
93
+ 10,
94
+ ge=1,
95
+ le=100,
96
+ description="Minimum JPEG quality"
97
+ )
98
+
99
+ # Logging
100
+ enable_logging: bool = Field(
101
+ True,
102
+ description="Enable logging"
103
+ )
104
+ log_level: str = Field(
105
+ "INFO",
106
+ description="Logging level"
107
+ )
108
+ log_file: Optional[str] = Field(
109
+ None,
110
+ description="Log file path"
111
+ )
112
+
113
+ # Security
114
+ validate_paths: bool = Field(
115
+ True,
116
+ description="Validate file paths against traversal attacks"
117
+ )
118
+ allowed_formats: List[str] = Field(
119
+ default_factory=lambda: [".jpg", ".jpeg", ".png", ".gif", ".webp"],
120
+ description="Allowed image formats"
121
+ )
122
+
123
+ @validator("log_level")
124
+ def validate_log_level(cls, v):
125
+ """Validate log level."""
126
+ valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
127
+ v_upper = v.upper()
128
+ if v_upper not in valid_levels:
129
+ raise ValueError(f"log_level must be one of {valid_levels}")
130
+ return v_upper
131
+
132
+ class Config:
133
+ env_prefix = "IMAGEOPS_"
134
+ case_sensitive = False
135
+
@@ -0,0 +1,11 @@
1
+ """Core module."""
2
+
3
+ from .processor import ImageProcessor
4
+ from .result import ProcessingResult, ImageOutput
5
+
6
+ __all__ = [
7
+ "ImageProcessor",
8
+ "ProcessingResult",
9
+ "ImageOutput",
10
+ ]
11
+
@@ -0,0 +1,179 @@
1
+ """Main ImageProcessor orchestrator."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import List, Literal, Optional
6
+ from ..config import ImageOpsConfig
7
+ from ..providers import get_provider
8
+ from ..utils import setup_logging, InputValidator, download_if_url, CleanupManager
9
+ from ..strategies import process_sequential, ProgressCallback
10
+ from ..exceptions import TimeoutError as ImageOpsTimeoutError
11
+
12
+ logger = logging.getLogger("imageops")
13
+
14
+
15
+ class ImageProcessor:
16
+ """Main image processor with context manager support."""
17
+
18
+ def __init__(
19
+ self,
20
+ provider: str = "anthropic",
21
+ config: Optional[ImageOpsConfig] = None
22
+ ):
23
+ """
24
+ Initialize ImageProcessor.
25
+
26
+ Args:
27
+ provider: Provider name (e.g., "anthropic")
28
+ config: Optional configuration
29
+ """
30
+ self.provider_name = provider
31
+ self.config = config or ImageOpsConfig()
32
+ self.provider = get_provider(provider)
33
+ self.cleanup_manager = CleanupManager()
34
+ self._setup_logging()
35
+
36
+ def _setup_logging(self):
37
+ """Setup logging based on config."""
38
+ if self.config.enable_logging:
39
+ setup_logging(
40
+ level=self.config.log_level,
41
+ log_file=self.config.log_file
42
+ )
43
+
44
+ async def __aenter__(self):
45
+ """Enter async context."""
46
+ logger.info(f"ImageProcessor started (provider: {self.provider_name})")
47
+ return self
48
+
49
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
50
+ """Exit async context - cleanup all temp files."""
51
+ logger.info("ImageProcessor cleanup started")
52
+ await self.cleanup_manager.cleanup_all(ignore_errors=True)
53
+ logger.info("ImageProcessor cleanup complete")
54
+ return False
55
+
56
+ async def process(
57
+ self,
58
+ image_source: str,
59
+ output_format: Literal["base64", "file"] = "base64",
60
+ progress_callback: Optional[ProgressCallback] = None,
61
+ timeout: Optional[float] = None,
62
+ thread_id: Optional[str] = None,
63
+ quality: int = 90,
64
+ **kwargs
65
+ ):
66
+ """
67
+ Process single image with timeout.
68
+
69
+ Args:
70
+ image_source: File path or URL
71
+ output_format: "base64" or "file"
72
+ progress_callback: Optional progress callback
73
+ timeout: Processing timeout in seconds
74
+ thread_id: Optional thread ID for temp directories
75
+ quality: JPEG quality (1-100)
76
+
77
+ Returns:
78
+ ProcessingResult object
79
+
80
+ Raises:
81
+ TimeoutError: If processing exceeds timeout
82
+ """
83
+ timeout = timeout or self.config.processing_timeout
84
+
85
+ try:
86
+ result = await asyncio.wait_for(
87
+ self._process_internal(
88
+ image_source,
89
+ output_format,
90
+ progress_callback,
91
+ thread_id,
92
+ quality
93
+ ),
94
+ timeout=timeout
95
+ )
96
+ return result
97
+
98
+ except asyncio.TimeoutError:
99
+ raise ImageOpsTimeoutError(
100
+ f"Processing timeout after {timeout}s: {image_source}"
101
+ )
102
+
103
+ async def _process_internal(
104
+ self,
105
+ image_source: str,
106
+ output_format: Literal["base64", "file"],
107
+ progress_callback: Optional[ProgressCallback],
108
+ thread_id: Optional[str],
109
+ quality: int
110
+ ):
111
+ """Internal processing logic."""
112
+ # Download if URL
113
+ local_path, is_temp = await download_if_url(
114
+ image_source,
115
+ timeout=self.config.download_timeout,
116
+ max_retries=self.config.max_download_retries,
117
+ backoff_factor=self.config.retry_backoff_factor
118
+ )
119
+
120
+ if is_temp:
121
+ self.cleanup_manager.track_file(local_path)
122
+
123
+ # Validate file path
124
+ validated_path = InputValidator.validate_file_path(
125
+ local_path,
126
+ max_size_mb=self.config.max_image_size_mb
127
+ )
128
+
129
+ # Process using sequential strategy
130
+ result = await process_sequential(
131
+ validated_path,
132
+ self.provider,
133
+ output_format,
134
+ thread_id,
135
+ progress_callback,
136
+ quality
137
+ )
138
+
139
+ return result
140
+
141
+ async def process_batch(
142
+ self,
143
+ image_sources: List[str],
144
+ output_format: Literal["base64", "file"] = "base64",
145
+ max_concurrent: Optional[int] = None,
146
+ **kwargs
147
+ ):
148
+ """
149
+ Process multiple images concurrently.
150
+
151
+ Args:
152
+ image_sources: List of file paths or URLs
153
+ output_format: "base64" or "file"
154
+ max_concurrent: Max concurrent operations
155
+
156
+ Returns:
157
+ List of ProcessingResult objects
158
+ """
159
+ # Validate batch size
160
+ if len(image_sources) > self.config.max_batch_size:
161
+ raise ValueError(
162
+ f"Batch size {len(image_sources)} exceeds limit "
163
+ f"of {self.config.max_batch_size}"
164
+ )
165
+
166
+ max_concurrent = max_concurrent or self.config.max_concurrent_ops
167
+ semaphore = asyncio.Semaphore(max_concurrent)
168
+
169
+ async def process_one(source: str):
170
+ async with semaphore:
171
+ return await self.process(source, output_format, **kwargs)
172
+
173
+ results = await asyncio.gather(
174
+ *[process_one(source) for source in image_sources],
175
+ return_exceptions=True
176
+ )
177
+
178
+ return results
179
+
@@ -0,0 +1,93 @@
1
+ """Result types and data classes."""
2
+
3
+ from datetime import datetime
4
+ from typing import Dict, List, Literal, Optional
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class ImageOutput(BaseModel):
9
+ """Single processed image output."""
10
+
11
+ # Based on output_format parameter
12
+ data: str = Field(..., description="base64 string OR file path")
13
+ format: Literal["base64", "file"] = Field(..., description="Output format")
14
+
15
+ # Metadata
16
+ media_type: str = Field(..., description="image/jpeg, image/png, etc.")
17
+ width: int = Field(..., gt=0, description="Image width in pixels")
18
+ height: int = Field(..., gt=0, description="Image height in pixels")
19
+ size_bytes: int = Field(..., ge=0, description="Image size in bytes")
20
+
21
+ # Processing info
22
+ was_resized: bool = Field(False, description="Whether image was resized")
23
+ was_compressed: bool = Field(False, description="Whether image was compressed")
24
+ was_sliced: bool = Field(False, description="Whether image was sliced")
25
+ compression_quality: Optional[int] = Field(None, ge=1, le=100, description="Compression quality used")
26
+
27
+ def to_data_uri(self) -> str:
28
+ """
29
+ Convert to data URI format with media type prefix.
30
+
31
+ Returns:
32
+ Data URI string like: data:image/jpeg;base64,<base64_data>
33
+
34
+ Example:
35
+ >>> img = ImageOutput(data="abc123...", format="base64", media_type="image/jpeg", ...)
36
+ >>> img.to_data_uri()
37
+ 'data:image/jpeg;base64,abc123...'
38
+ """
39
+ if self.format != "base64":
40
+ raise ValueError("to_data_uri() only works with base64 format")
41
+
42
+ return f"data:{self.media_type};base64,{self.data}"
43
+
44
+ class Config:
45
+ frozen = True # Immutable
46
+
47
+
48
+ class ProcessingResult(BaseModel):
49
+ """Complete processing result."""
50
+
51
+ success: bool = Field(..., description="Processing success status")
52
+
53
+ # Outputs
54
+ images: List[ImageOutput] = Field(..., description="Single image OR multiple if sliced")
55
+ output_format: Literal["base64", "file"] = Field(..., description="Output format")
56
+
57
+ # Original info
58
+ original_path: str = Field(..., description="Original input path")
59
+ original_metadata: Dict = Field(..., description="Original image metadata")
60
+
61
+ # Processing stats
62
+ provider: str = Field(..., description="Provider name")
63
+ strategy_used: str = Field(..., description="Processing strategy used")
64
+ processing_time_ms: float = Field(..., ge=0, description="Processing time in milliseconds")
65
+ was_sliced: bool = Field(..., description="Whether image was sliced")
66
+ slice_count: int = Field(..., ge=1, description="Number of slices")
67
+ created_at: datetime = Field(default_factory=datetime.utcnow, description="Result creation timestamp")
68
+
69
+ def to_anthropic_format(self) -> List[Dict]:
70
+ """
71
+ Convert to Anthropic message content format.
72
+
73
+ Returns:
74
+ List of formatted image content dicts
75
+ """
76
+ return [
77
+ {
78
+ "type": "image",
79
+ "source": {
80
+ "type": "base64" if img.format == "base64" else "url",
81
+ "media_type": img.media_type,
82
+ "data": img.data if img.format == "base64" else None
83
+ }
84
+ }
85
+ for img in self.images
86
+ ]
87
+
88
+ async def cleanup(self):
89
+ """Clean up temp files if output_format='file'."""
90
+ if self.output_format == "file":
91
+ from ..utils.cleanup import cleanup_files
92
+ await cleanup_files([img.data for img in self.images])
93
+
imageops/exceptions.py ADDED
@@ -0,0 +1,57 @@
1
+ """Comprehensive exception hierarchy for imageops."""
2
+
3
+
4
+ class ImageOpsError(Exception):
5
+ """Base exception for all imageops errors."""
6
+ pass
7
+
8
+
9
+ class ImageDownloadError(ImageOpsError):
10
+ """Failed to download image from URL."""
11
+ pass
12
+
13
+
14
+ class ImageValidationError(ImageOpsError):
15
+ """Image validation failed (format, size, etc.)."""
16
+ pass
17
+
18
+
19
+ class ImageProcessingError(ImageOpsError):
20
+ """Error during image processing operations."""
21
+ pass
22
+
23
+
24
+ class ImageCorruptedError(ImageOpsError):
25
+ """Image file is corrupted or unreadable."""
26
+ pass
27
+
28
+
29
+ class ImageTooLargeError(ImageOpsError):
30
+ """Image exceeds size or dimension limits."""
31
+ pass
32
+
33
+
34
+ class UnsupportedFormatError(ImageOpsError):
35
+ """Image format is not supported."""
36
+ pass
37
+
38
+
39
+ class CompressionFailedError(ImageProcessingError):
40
+ """Cannot compress image to required size."""
41
+ pass
42
+
43
+
44
+ class ProviderNotFoundError(ImageOpsError):
45
+ """Unknown or unsupported provider."""
46
+ pass
47
+
48
+
49
+ class TimeoutError(ImageOpsError):
50
+ """Operation timed out."""
51
+ pass
52
+
53
+
54
+ class ResourceLimitError(ImageOpsError):
55
+ """Resource limit exceeded (memory, file handles, etc.)."""
56
+ pass
57
+
@@ -0,0 +1,20 @@
1
+ """Operations module."""
2
+
3
+ from .compress import compress_to_size
4
+ from .encode import encode_to_base64, get_image_with_media_type
5
+ from .resize import resize_width, resize_height, fix_exif_orientation, handle_alpha_channel
6
+ from .slice import slice_by_height, handle_animated_gif, handle_wide_panorama
7
+
8
+ __all__ = [
9
+ "compress_to_size",
10
+ "encode_to_base64",
11
+ "get_image_with_media_type",
12
+ "resize_width",
13
+ "resize_height",
14
+ "fix_exif_orientation",
15
+ "handle_alpha_channel",
16
+ "slice_by_height",
17
+ "handle_animated_gif",
18
+ "handle_wide_panorama",
19
+ ]
20
+