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

1"""File Upload Service - Refactored to use lexigram-storage. 

2 

3This service replaces the custom LocalStorageBackend with lexigram-storage 

4for S3/Azure/GCS 

5File upload service with validation and storage. 

6 

7Provides a clean API for handling file uploads with validation, 

8storage backends, and security checks. 

9""" 

10 

11from __future__ import annotations 

12 

13from dataclasses import dataclass 

14from enum import StrEnum 

15import hashlib 

16from pathlib import Path 

17from typing import BinaryIO 

18import uuid 

19 

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 

28 

29logger = get_logger(__name__) 

30 

31 

32class StorageBackend(StrEnum): 

33 """Supported storage backends.""" 

34 

35 LOCAL = "local" 

36 S3 = "s3" 

37 AZURE = "azure" 

38 GCS = "gcs" 

39 MEMORY = "memory" # For testing 

40 

41 

42@dataclass 

43class UploadedFile: 

44 """Uploaded file metadata.""" 

45 

46 filename: str 

47 content_type: str 

48 size: int 

49 storage_path: str 

50 url: str 

51 hash: str = "" 

52 

53 

54@inject 

55class FileValidator: 

56 """File upload validator.""" 

57 

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. 

66 

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() 

75 

76 def validate(self, filename: str, size: int, content_type: str) -> tuple[bool, str]: 

77 """ 

78 Validate file. 

79 

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})" 

86 

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}" 

92 

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}" 

96 

97 return True, "" 

98 

99 

100@inject 

101class FileUploadService: 

102 """DI-injectable file upload service using lexigram-storage. 

103 

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 """ 

111 

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. 

120 

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 

129 

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. 

139 

140 Args: 

141 file: File-like object 

142 filename: Original filename 

143 content_type: MIME type 

144 public: Whether file should be publicly accessible 

145 

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) 

152 

153 # Validate 

154 is_valid, error = self.validator.validate(filename, size, content_type) 

155 if not is_valid: 

156 return None, error 

157 

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}" 

162 

163 # Calculate hash 

164 hasher = hashlib.sha256() 

165 hasher.update(content) 

166 file_hash = hasher.hexdigest() 

167 

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 ) 

179 

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 

185 

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 

193 

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) 

201 

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 ) 

213 

214 except (OSError, RuntimeError, AttributeError): 

215 from lexigram.logging import get_logger 

216 

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 

225 

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" 

230 

231 async def delete(self, storage_path: str) -> bool: 

232 """ 

233 Delete uploaded file. 

234 

235 Args: 

236 storage_path: Path to file in storage 

237 

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 

246 

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 

253 

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 

258 

259 async def get_url(self, storage_path: str, expires_in: int = 3600) -> str: 

260 """ 

261 Get presigned URL for file. 

262 

263 Args: 

264 storage_path: Path to file in storage 

265 expires_in: URL expiration time in seconds 

266 

267 Returns: 

268 Presigned URL 

269 """ 

270 from datetime import timedelta 

271 

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) 

283 

284 async def exists(self, storage_path: str) -> bool: 

285 """ 

286 Check if file exists. 

287 

288 Args: 

289 storage_path: Path to file in storage 

290 

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 

299 

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 

306 

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