winebox 0.1.2__py3-none-any.whl → 0.1.4__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.
@@ -4,42 +4,171 @@ import uuid
4
4
  from pathlib import Path
5
5
 
6
6
  import aiofiles
7
- from fastapi import UploadFile
7
+ from fastapi import HTTPException, UploadFile, status
8
8
 
9
9
  from winebox.config import settings
10
10
 
11
+ # Allowed MIME types for image uploads
12
+ ALLOWED_MIME_TYPES = {
13
+ "image/jpeg",
14
+ "image/png",
15
+ "image/gif",
16
+ "image/webp",
17
+ }
18
+
19
+ # File extension to MIME type mapping
20
+ EXTENSION_MIME_MAP = {
21
+ ".jpg": "image/jpeg",
22
+ ".jpeg": "image/jpeg",
23
+ ".png": "image/png",
24
+ ".gif": "image/gif",
25
+ ".webp": "image/webp",
26
+ }
27
+
28
+ # Magic byte signatures for image formats
29
+ # Each entry is (magic_bytes, offset, detected_extension)
30
+ IMAGE_MAGIC_SIGNATURES = [
31
+ # JPEG: starts with FF D8 FF
32
+ (b"\xff\xd8\xff", 0, ".jpg"),
33
+ # PNG: starts with 89 50 4E 47 0D 0A 1A 0A
34
+ (b"\x89PNG\r\n\x1a\n", 0, ".png"),
35
+ # GIF87a and GIF89a
36
+ (b"GIF87a", 0, ".gif"),
37
+ (b"GIF89a", 0, ".gif"),
38
+ # WebP: starts with RIFF....WEBP
39
+ (b"RIFF", 0, ".webp"), # Additional check for WEBP at offset 8
40
+ ]
41
+
42
+
43
+ def detect_image_type(content: bytes) -> str | None:
44
+ """Detect image type from file content using magic bytes.
45
+
46
+ Args:
47
+ content: The file content bytes.
48
+
49
+ Returns:
50
+ The detected extension (e.g., ".jpg") or None if not a valid image.
51
+ """
52
+ if len(content) < 12:
53
+ return None
54
+
55
+ for magic, offset, ext in IMAGE_MAGIC_SIGNATURES:
56
+ if content[offset:offset + len(magic)] == magic:
57
+ # Special case for WebP: verify WEBP signature at offset 8
58
+ if ext == ".webp":
59
+ if content[8:12] != b"WEBP":
60
+ continue
61
+ return ext
62
+
63
+ return None
64
+
65
+
66
+ class FileSizeExceededError(Exception):
67
+ """Raised when uploaded file exceeds size limit."""
68
+
69
+ pass
70
+
71
+
72
+ class InvalidFileTypeError(Exception):
73
+ """Raised when uploaded file has invalid type."""
74
+
75
+ pass
76
+
77
+
78
+ class InvalidMagicBytesError(Exception):
79
+ """Raised when file content doesn't match a valid image format."""
80
+
81
+ pass
82
+
11
83
 
12
84
  class ImageStorageService:
13
85
  """Service for storing and managing wine label images."""
14
86
 
15
- def __init__(self, storage_path: Path | None = None) -> None:
87
+ def __init__(
88
+ self,
89
+ storage_path: Path | None = None,
90
+ max_size_bytes: int | None = None,
91
+ ) -> None:
16
92
  """Initialize the image storage service.
17
93
 
18
94
  Args:
19
95
  storage_path: Path to store images. Defaults to config setting.
96
+ max_size_bytes: Maximum file size in bytes. Defaults to config setting.
20
97
  """
21
98
  self.storage_path = storage_path or settings.image_storage_path
22
99
  self.storage_path.mkdir(parents=True, exist_ok=True)
100
+ self.max_size_bytes = max_size_bytes or settings.max_upload_size_bytes
101
+
102
+ def _validate_extension(self, filename: str | None) -> str:
103
+ """Validate and return the file extension.
104
+
105
+ Args:
106
+ filename: The original filename.
107
+
108
+ Returns:
109
+ Valid extension (e.g., ".jpg").
110
+
111
+ Raises:
112
+ InvalidFileTypeError: If extension is not allowed.
113
+ """
114
+ if not filename:
115
+ return ".jpg"
116
+
117
+ ext = Path(filename).suffix.lower()
118
+ if ext not in EXTENSION_MIME_MAP:
119
+ raise InvalidFileTypeError(
120
+ f"Invalid file type. Allowed types: {', '.join(EXTENSION_MIME_MAP.keys())}"
121
+ )
122
+ return ext
23
123
 
24
124
  async def save_image(self, upload_file: UploadFile) -> str:
25
- """Save an uploaded image file.
125
+ """Save an uploaded image file with size, type, and content validation.
26
126
 
27
127
  Args:
28
128
  upload_file: The uploaded file from FastAPI.
29
129
 
30
130
  Returns:
31
131
  The filename of the saved image.
32
- """
33
- # Generate unique filename
34
- ext = Path(upload_file.filename or "image.jpg").suffix.lower()
35
- if ext not in [".jpg", ".jpeg", ".png", ".gif", ".webp"]:
36
- ext = ".jpg"
37
132
 
133
+ Raises:
134
+ HTTPException: If file exceeds size limit, has invalid type, or
135
+ content doesn't match a valid image format.
136
+ """
137
+ # Validate extension first
138
+ try:
139
+ declared_ext = self._validate_extension(upload_file.filename)
140
+ except InvalidFileTypeError as e:
141
+ raise HTTPException(
142
+ status_code=status.HTTP_400_BAD_REQUEST,
143
+ detail=str(e),
144
+ )
145
+
146
+ # Read content with size limit check
147
+ content = await upload_file.read()
148
+ if len(content) > self.max_size_bytes:
149
+ max_mb = self.max_size_bytes / (1024 * 1024)
150
+ raise HTTPException(
151
+ status_code=status.HTTP_413_CONTENT_TOO_LARGE,
152
+ detail=f"File size exceeds maximum allowed size of {max_mb:.1f} MB",
153
+ )
154
+
155
+ # Validate magic bytes - ensure file content matches a valid image format
156
+ detected_ext = detect_image_type(content)
157
+ if detected_ext is None:
158
+ raise HTTPException(
159
+ status_code=status.HTTP_400_BAD_REQUEST,
160
+ detail="Invalid file content. File does not appear to be a valid image.",
161
+ )
162
+
163
+ # Use the detected extension (more reliable than declared extension)
164
+ # This prevents attacks where malicious files are renamed to .jpg/.png
165
+ ext = detected_ext
166
+
167
+ # Generate unique filename with detected extension
38
168
  filename = f"{uuid.uuid4()}{ext}"
39
169
  file_path = self.storage_path / filename
40
170
 
41
171
  # Save file
42
- content = await upload_file.read()
43
172
  async with aiofiles.open(file_path, "wb") as f:
44
173
  await f.write(content)
45
174
 
winebox/services/ocr.py CHANGED
@@ -1,5 +1,6 @@
1
1
  """OCR service for extracting text from wine label images."""
2
2
 
3
+ import io
3
4
  import logging
4
5
  from pathlib import Path
5
6
 
@@ -66,6 +67,42 @@ class OCRService:
66
67
  logger.error(f"OCR extraction failed: {e}")
67
68
  return ""
68
69
 
70
+ async def extract_text_from_bytes(self, image_data: bytes) -> str:
71
+ """Extract text from image bytes without saving to disk.
72
+
73
+ Args:
74
+ image_data: Raw image data as bytes.
75
+
76
+ Returns:
77
+ Extracted text from the image.
78
+ """
79
+ try:
80
+ import pytesseract
81
+
82
+ # Open image from bytes
83
+ image = Image.open(io.BytesIO(image_data))
84
+
85
+ # Preprocess image for better OCR results
86
+ # Convert to grayscale
87
+ if image.mode != "L":
88
+ image = image.convert("L")
89
+
90
+ # Extract text
91
+ text = pytesseract.image_to_string(
92
+ image,
93
+ lang="eng",
94
+ config="--psm 6", # Assume uniform block of text
95
+ )
96
+
97
+ return text.strip()
98
+
99
+ except ImportError:
100
+ logger.error("pytesseract is not installed")
101
+ return ""
102
+ except Exception as e:
103
+ logger.error(f"OCR extraction failed: {e}")
104
+ return ""
105
+
69
106
  async def extract_text_with_confidence(
70
107
  self, image_path: str | Path
71
108
  ) -> tuple[str, float]:
@@ -0,0 +1,278 @@
1
+ """Claude Vision service for wine label analysis."""
2
+
3
+ import base64
4
+ import json
5
+ import logging
6
+ import os
7
+ from typing import Any
8
+
9
+ from winebox.config import settings
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ WINE_ANALYSIS_PROMPT = """Analyze this wine label image and extract the following information.
14
+ Return ONLY a valid JSON object with these fields (use null for any field you cannot determine):
15
+
16
+ {
17
+ "name": "The wine name/title",
18
+ "winery": "The winery or producer name",
19
+ "vintage": 2020,
20
+ "grape_variety": "The grape variety (e.g., Cabernet Sauvignon, Chardonnay)",
21
+ "region": "The wine region (e.g., Napa Valley, Bordeaux)",
22
+ "country": "The country of origin",
23
+ "alcohol_percentage": 13.5,
24
+ "raw_text": "All readable text from the label, preserving line breaks"
25
+ }
26
+
27
+ Important:
28
+ - vintage should be a number (year) or null
29
+ - alcohol_percentage should be a number or null
30
+ - Extract ALL visible text for raw_text, including small print
31
+ - If you see multiple wines or labels, focus on the main/primary one
32
+ - Be thorough - wine labels often have text in multiple locations"""
33
+
34
+
35
+ class ClaudeVisionService:
36
+ """Service for analyzing wine labels using Claude's vision capabilities."""
37
+
38
+ def __init__(self) -> None:
39
+ """Initialize the Claude Vision service."""
40
+ self._default_client = None
41
+
42
+ def _get_system_api_key(self) -> str | None:
43
+ """Get the system-wide API key from settings or environment."""
44
+ return settings.anthropic_api_key or os.getenv("ANTHROPIC_API_KEY")
45
+
46
+ def _get_client(self, user_api_key: str | None = None):
47
+ """Get an Anthropic client, using user key if provided, else system key."""
48
+ try:
49
+ import anthropic
50
+
51
+ # Use user's API key if provided, otherwise use system key
52
+ api_key = user_api_key or self._get_system_api_key()
53
+ if not api_key:
54
+ raise ValueError("No Anthropic API key configured")
55
+
56
+ # If using system key, cache the client
57
+ if not user_api_key:
58
+ if self._default_client is None:
59
+ self._default_client = anthropic.Anthropic(api_key=api_key)
60
+ return self._default_client
61
+
62
+ # Create a new client for user-specific key
63
+ return anthropic.Anthropic(api_key=api_key)
64
+ except ImportError:
65
+ logger.error("anthropic package is not installed")
66
+ raise
67
+
68
+ @property
69
+ def client(self):
70
+ """Lazy-load the default Anthropic client (for backward compatibility)."""
71
+ return self._get_client()
72
+
73
+ def is_available(self, user_api_key: str | None = None) -> bool:
74
+ """Check if Claude Vision is available.
75
+
76
+ Args:
77
+ user_api_key: Optional user-specific API key to check.
78
+ """
79
+ try:
80
+ api_key = user_api_key or self._get_system_api_key()
81
+ return bool(api_key) and settings.use_claude_vision
82
+ except Exception:
83
+ return False
84
+
85
+ async def analyze_label(
86
+ self,
87
+ image_data: bytes,
88
+ media_type: str = "image/jpeg",
89
+ user_api_key: str | None = None,
90
+ ) -> dict[str, Any]:
91
+ """Analyze a wine label image using Claude Vision.
92
+
93
+ Args:
94
+ image_data: Raw image data as bytes.
95
+ media_type: MIME type of the image (image/jpeg, image/png, etc.)
96
+ user_api_key: Optional user-specific API key.
97
+
98
+ Returns:
99
+ Dictionary with parsed wine information.
100
+ """
101
+ try:
102
+ # Encode image to base64
103
+ image_base64 = base64.standard_b64encode(image_data).decode("utf-8")
104
+
105
+ # Get client (uses user key if provided, else system key)
106
+ client = self._get_client(user_api_key)
107
+
108
+ # Call Claude API with vision
109
+ message = client.messages.create(
110
+ model="claude-sonnet-4-20250514",
111
+ max_tokens=1024,
112
+ messages=[
113
+ {
114
+ "role": "user",
115
+ "content": [
116
+ {
117
+ "type": "image",
118
+ "source": {
119
+ "type": "base64",
120
+ "media_type": media_type,
121
+ "data": image_base64,
122
+ },
123
+ },
124
+ {
125
+ "type": "text",
126
+ "text": WINE_ANALYSIS_PROMPT,
127
+ },
128
+ ],
129
+ }
130
+ ],
131
+ )
132
+
133
+ # Extract the response text
134
+ response_text = message.content[0].text
135
+
136
+ # Parse JSON from response
137
+ # Handle case where Claude might wrap JSON in markdown code blocks
138
+ if "```json" in response_text:
139
+ response_text = response_text.split("```json")[1].split("```")[0]
140
+ elif "```" in response_text:
141
+ response_text = response_text.split("```")[1].split("```")[0]
142
+
143
+ result = json.loads(response_text.strip())
144
+
145
+ # Ensure all expected fields exist
146
+ return {
147
+ "name": result.get("name"),
148
+ "winery": result.get("winery"),
149
+ "vintage": result.get("vintage"),
150
+ "grape_variety": result.get("grape_variety"),
151
+ "region": result.get("region"),
152
+ "country": result.get("country"),
153
+ "alcohol_percentage": result.get("alcohol_percentage"),
154
+ "raw_text": result.get("raw_text", ""),
155
+ }
156
+
157
+ except json.JSONDecodeError as e:
158
+ logger.error(f"Failed to parse Claude response as JSON: {e}")
159
+ logger.debug(f"Response was: {response_text}")
160
+ return self._empty_result()
161
+ except Exception as e:
162
+ logger.error(f"Claude Vision analysis failed: {e}")
163
+ return self._empty_result()
164
+
165
+ async def analyze_labels(
166
+ self,
167
+ front_image_data: bytes,
168
+ back_image_data: bytes | None = None,
169
+ front_media_type: str = "image/jpeg",
170
+ back_media_type: str = "image/jpeg",
171
+ user_api_key: str | None = None,
172
+ ) -> dict[str, Any]:
173
+ """Analyze front and back wine label images.
174
+
175
+ Args:
176
+ front_image_data: Front label image data.
177
+ back_image_data: Optional back label image data.
178
+ front_media_type: MIME type of front image.
179
+ back_media_type: MIME type of back image.
180
+ user_api_key: Optional user-specific API key.
181
+
182
+ Returns:
183
+ Combined analysis results.
184
+ """
185
+ try:
186
+ # Get client (uses user key if provided, else system key)
187
+ client = self._get_client(user_api_key)
188
+
189
+ # Build message content with images
190
+ content = [
191
+ {
192
+ "type": "image",
193
+ "source": {
194
+ "type": "base64",
195
+ "media_type": front_media_type,
196
+ "data": base64.standard_b64encode(front_image_data).decode("utf-8"),
197
+ },
198
+ },
199
+ {
200
+ "type": "text",
201
+ "text": "Front label:" if back_image_data else WINE_ANALYSIS_PROMPT,
202
+ },
203
+ ]
204
+
205
+ if back_image_data:
206
+ content.extend([
207
+ {
208
+ "type": "image",
209
+ "source": {
210
+ "type": "base64",
211
+ "media_type": back_media_type,
212
+ "data": base64.standard_b64encode(back_image_data).decode("utf-8"),
213
+ },
214
+ },
215
+ {
216
+ "type": "text",
217
+ "text": "Back label:",
218
+ },
219
+ {
220
+ "type": "text",
221
+ "text": WINE_ANALYSIS_PROMPT.replace(
222
+ "this wine label image",
223
+ "these wine label images (front and back)"
224
+ ),
225
+ },
226
+ ])
227
+
228
+ # Call Claude API
229
+ message = client.messages.create(
230
+ model="claude-sonnet-4-20250514",
231
+ max_tokens=1024,
232
+ messages=[{"role": "user", "content": content}],
233
+ )
234
+
235
+ response_text = message.content[0].text
236
+
237
+ # Parse JSON
238
+ if "```json" in response_text:
239
+ response_text = response_text.split("```json")[1].split("```")[0]
240
+ elif "```" in response_text:
241
+ response_text = response_text.split("```")[1].split("```")[0]
242
+
243
+ result = json.loads(response_text.strip())
244
+
245
+ return {
246
+ "name": result.get("name"),
247
+ "winery": result.get("winery"),
248
+ "vintage": result.get("vintage"),
249
+ "grape_variety": result.get("grape_variety"),
250
+ "region": result.get("region"),
251
+ "country": result.get("country"),
252
+ "alcohol_percentage": result.get("alcohol_percentage"),
253
+ "raw_text": result.get("raw_text", ""),
254
+ "front_label_text": result.get("raw_text", ""),
255
+ "back_label_text": None, # Combined in raw_text
256
+ }
257
+
258
+ except json.JSONDecodeError as e:
259
+ logger.error(f"Failed to parse Claude response as JSON: {e}")
260
+ return self._empty_result()
261
+ except Exception as e:
262
+ logger.error(f"Claude Vision analysis failed: {e}")
263
+ return self._empty_result()
264
+
265
+ def _empty_result(self) -> dict[str, Any]:
266
+ """Return an empty result dictionary."""
267
+ return {
268
+ "name": None,
269
+ "winery": None,
270
+ "vintage": None,
271
+ "grape_variety": None,
272
+ "region": None,
273
+ "country": None,
274
+ "alcohol_percentage": None,
275
+ "raw_text": "",
276
+ "front_label_text": "",
277
+ "back_label_text": None,
278
+ }