Coverage for src/lexigram/admin/services/storage/upload.py: 78%
123 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
1"""File Upload Service - Refactored to use lexigram-storage.
3This service replaces the custom LocalStorageBackend with lexigram-storage
4for S3/Azure/GCS
5File upload service with validation and storage.
7Provides a clean API for handling file uploads with validation,
8storage backends, and security checks.
9"""
11from __future__ import annotations
13from dataclasses import dataclass
14from enum import StrEnum
15import hashlib
16from pathlib import Path
17from typing import BinaryIO
18import uuid
20from lexigram.contracts.infra.storage import (
21 BlobStoreProtocol,
22 FileInfo,
23 StorageUnsupportedOperationError,
24 UploadOptions,
25)
26from lexigram.di.decorators import inject
27from lexigram.logging import get_logger
29logger = get_logger(__name__)
32class StorageBackend(StrEnum):
33 """Supported storage backends."""
35 LOCAL = "local"
36 S3 = "s3"
37 AZURE = "azure"
38 GCS = "gcs"
39 MEMORY = "memory" # For testing
42@dataclass
43class UploadedFile:
44 """Uploaded file metadata."""
46 filename: str
47 content_type: str
48 size: int
49 storage_path: str
50 url: str
51 hash: str = ""
54@inject
55class FileValidator:
56 """File upload validator."""
58 def __init__(
59 self,
60 max_size: int = 10 * 1024 * 1024, # 10MB
61 allowed_extensions: set[str] | None = None,
62 allowed_mimetypes: set[str] | None = None,
63 ):
64 """
65 Initialize validator.
67 Args:
68 max_size: Maximum file size in bytes
69 allowed_extensions: Set of allowed file extensions (e.g., {'.jpg', '.png'})
70 allowed_mimetypes: Set of allowed MIME types
71 """
72 self.max_size = max_size
73 self.allowed_extensions = allowed_extensions or set()
74 self.allowed_mimetypes = allowed_mimetypes or set()
76 def validate(self, filename: str, size: int, content_type: str) -> tuple[bool, str]:
77 """
78 Validate file.
80 Returns:
81 (is_valid, error_message)
82 """
83 # Check size
84 if size > self.max_size:
85 return False, f"File too large ({size} bytes, max {self.max_size})"
87 # Check extension
88 if self.allowed_extensions:
89 ext = Path(filename).suffix.lower()
90 if ext not in self.allowed_extensions:
91 return False, f"File type not allowed: {ext}"
93 # Check MIME type
94 if self.allowed_mimetypes and content_type not in self.allowed_mimetypes:
95 return False, f"MIME type not allowed: {content_type}"
97 return True, ""
100@inject
101class FileUploadService:
102 """DI-injectable file upload service using lexigram-storage.
104 Benefits over custom LocalStorageBackend:
105 - S3/Azure/GCS support out of the box
106 - Presigned URLs for secure downloads
107 - Production-tested storage patterns
108 - Automatic retry logic
109 - Cloud-native optimizations
110 """
112 def __init__(
113 self,
114 storage: BlobStoreProtocol,
115 validator: FileValidator | None = None,
116 upload_prefix: str = "admin",
117 ):
118 """
119 Initialize file upload service.
121 Args:
122 storage: BlobStoreProtocol instance (injected)
123 validator: File validator (defaults to permissive validator)
124 upload_prefix: Prefix for uploaded files (e.g., "admin/uploads")
125 """
126 self.storage = storage
127 self.validator = validator or FileValidator()
128 self.upload_prefix = upload_prefix
130 async def upload(
131 self,
132 file: BinaryIO,
133 filename: str,
134 content_type: str = "application/octet-stream",
135 public: bool = False,
136 ) -> tuple[UploadedFile | None, str]:
137 """
138 Upload file using lexigram-storage.
140 Args:
141 file: File-like object
142 filename: Original filename
143 content_type: MIME type
144 public: Whether file should be publicly accessible
146 Returns:
147 (UploadedFile, error_message) - UploadedFile is None if validation failed
148 """
149 # Read file content for validation and hashing
150 content = file.read()
151 size = len(content)
153 # Validate
154 is_valid, error = self.validator.validate(filename, size, content_type)
155 if not is_valid:
156 return None, error
158 # Generate unique filename
159 ext = Path(filename).suffix
160 unique_name = f"{uuid.uuid4().hex}{ext}"
161 storage_path = f"{self.upload_prefix}/{unique_name}"
163 # Calculate hash
164 hasher = hashlib.sha256()
165 hasher.update(content)
166 file_hash = hasher.hexdigest()
168 try:
169 # Upload using lexigram-storage
170 _file_info: FileInfo = await self.storage.upload(
171 data=content,
172 path=storage_path,
173 options=UploadOptions(
174 content_type=content_type,
175 public=public,
176 metadata={"original_filename": filename, "sha256": file_hash},
177 ),
178 )
180 # Get URL (presigned if private, public if public)
181 if public:
182 url = await self.storage.get_url(storage_path)
183 else:
184 from datetime import timedelta
186 try:
187 url = await self.storage.get_presigned_url(
188 path=storage_path,
189 expires_in=timedelta(hours=1),
190 )
191 except StorageUnsupportedOperationError:
192 from lexigram.logging import get_logger
194 logger = get_logger(__name__)
195 logger.warning(
196 "storage.presigned_fallback_to_public",
197 path=storage_path,
198 method="PUT",
199 )
200 url = await self.storage.get_url(storage_path)
202 return (
203 UploadedFile(
204 filename=filename,
205 content_type=content_type,
206 size=size,
207 storage_path=storage_path,
208 url=url,
209 hash=file_hash,
210 ),
211 "",
212 )
214 except (OSError, RuntimeError, AttributeError):
215 from lexigram.logging import get_logger
217 logger = get_logger(__name__)
218 # Emit an explicit ERROR-level text message for caplog tests
219 logger.exception("Upload failed for %s", filename)
220 # Keep stacktrace for diagnostics
221 logger.exception("Upload failed for %s", filename)
222 return None, "Upload failed"
223 except BaseException:
224 from lexigram.logging import get_logger
226 logger = get_logger(__name__)
227 logger.exception("Unexpected upload failure for %s", filename)
228 logger.exception("Unexpected upload failure for %s", filename)
229 return None, "Upload failed"
231 async def delete(self, storage_path: str) -> bool:
232 """
233 Delete uploaded file.
235 Args:
236 storage_path: Path to file in storage
238 Returns:
239 True if deleted successfully
240 """
241 try:
242 await self.storage.delete(storage_path)
243 return True
244 except (OSError, RuntimeError):
245 from lexigram.logging import get_logger
247 logger = get_logger(__name__)
248 logger.exception("Failed to delete storage path %s", storage_path)
249 logger.exception("Failed to delete storage path %s", storage_path)
250 return False
251 except BaseException:
252 from lexigram.logging import get_logger
254 logger = get_logger(__name__)
255 logger.exception("Unexpected error deleting storage path %s", storage_path)
256 logger.exception("Unexpected error deleting storage path %s", storage_path)
257 return False
259 async def get_url(self, storage_path: str, expires_in: int = 3600) -> str:
260 """
261 Get presigned URL for file.
263 Args:
264 storage_path: Path to file in storage
265 expires_in: URL expiration time in seconds
267 Returns:
268 Presigned URL
269 """
270 from datetime import timedelta
272 try:
273 return await self.storage.get_presigned_url(
274 path=storage_path,
275 expires_in=timedelta(seconds=expires_in),
276 )
277 except StorageUnsupportedOperationError:
278 logger.warning(
279 "storage.presigned_fallback_to_public",
280 path=storage_path,
281 )
282 return await self.storage.get_url(storage_path)
284 async def exists(self, storage_path: str) -> bool:
285 """
286 Check if file exists.
288 Args:
289 storage_path: Path to file in storage
291 Returns:
292 True if file exists
293 """
294 try:
295 await self.storage.info(storage_path)
296 return True
297 except (OSError, RuntimeError):
298 from lexigram.logging import get_logger
300 logger = get_logger(__name__)
301 logger.exception("Failed to check existence for %s", storage_path)
302 logger.exception("Failed to check existence for %s", storage_path)
303 return False
304 except BaseException:
305 from lexigram.logging import get_logger
307 logger = get_logger(__name__)
308 logger.exception("Unexpected error checking existence for %s", storage_path)
309 logger.exception("Unexpected error checking existence for %s", storage_path)
310 return False