Coverage for src / lexigram / admin / services / storage / service.py: 26%

85 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-13 22:14 +0800

1""" 

2Main storage service implementation. 

3 

4Provides the AdminStorageService class for file upload management. 

5""" 

6 

7from __future__ import annotations 

8 

9from collections.abc import AsyncIterator 

10from datetime import UTC, datetime 

11import hashlib 

12from typing import Any, BinaryIO 

13 

14from lexigram.admin.config import AdminStorageConfig 

15from lexigram.admin.exceptions import DataError 

16from lexigram.admin.services.storage.types import ( 

17 AdminFileInfo, 

18 AdminUploadOptions, 

19) 

20from lexigram.admin.services.storage.utils import get_content_type 

21from lexigram.contracts.infra.storage import BlobStoreProtocol, FileInfo 

22from lexigram.di.decorators import inject 

23from lexigram.result import Err, Ok, Result 

24 

25 

26@inject 

27class AdminStorageService: 

28 """Storage service for admin file management. 

29 

30 Provides a unified interface for file uploads with: 

31 - Validation (size, type) 

32 - Path generation 

33 - Presigned URLs 

34 - Resource association 

35 

36 Example: 

37 >>> storage = AdminStorageService(blob_store, config) 

38 >>> 

39 >>> # Upload a file 

40 >>> result = await storage.upload( 

41 ... data=file_bytes, 

42 ... filename="avatar.jpg", 

43 ... options=AdminUploadOptions( 

44 ... resource_type="users", 

45 ... resource_id=123, 

46 ... ), 

47 ... ) 

48 >>> 

49 >>> # Get presigned URL 

50 >>> url = await storage.get_download_url(result.file_info.path) 

51 """ 

52 

53 def __init__( 

54 self, 

55 blob_store: BlobStoreProtocol, 

56 config: AdminStorageConfig | None = None, 

57 ): 

58 self.config = config or AdminStorageConfig() 

59 self._store: BlobStoreProtocol = blob_store 

60 

61 def validate_upload( 

62 self, 

63 data: bytes | BinaryIO, 

64 filename: str, 

65 options: AdminUploadOptions | None = None, 

66 ) -> tuple[bool, str | None]: 

67 """Validate file before upload. 

68 

69 Args: 

70 data: File content 

71 filename: Original filename 

72 options: Upload options 

73 

74 Returns: 

75 (is_valid, error_message) 

76 """ 

77 # Get content type 

78 content_type = options.content_type if options else None 

79 if not content_type: 

80 content_type = get_content_type(filename) 

81 

82 # Check content type 

83 allowed_types = ( 

84 options.allowed_types 

85 if options and options.allowed_types 

86 else self.config.allowed_content_types 

87 ) 

88 if allowed_types and content_type not in allowed_types: 

89 return False, f"Content type not allowed: {content_type}" 

90 

91 # Check size 

92 max_size = ( 

93 options.max_size 

94 if options and options.max_size 

95 else self.config.max_file_size 

96 ) 

97 if isinstance(data, bytes): 

98 size = len(data) 

99 elif hasattr(data, "seek") and hasattr(data, "tell"): 

100 pos = data.tell() 

101 data.seek(0, 2) 

102 size = data.tell() 

103 data.seek(pos) 

104 else: 

105 size = 0 # Can't determine 

106 

107 if max_size and size > max_size: 

108 return False, f"File too large: {size} bytes (max: {max_size})" 

109 

110 return True, None 

111 

112 async def upload( 

113 self, 

114 data: bytes | BinaryIO | AsyncIterator[bytes], 

115 filename: str, 

116 options: AdminUploadOptions | None = None, 

117 uploaded_by: Any = None, 

118 ) -> Result[AdminFileInfo, DataError]: 

119 """Upload a file. 

120 

121 Args: 

122 data: File content 

123 filename: Original filename 

124 options: Upload options 

125 uploaded_by: User who uploaded the file 

126 

127 Returns: 

128 Result containing AdminFileInfo on success, DataError on failure. 

129 """ 

130 # Validate (only for bytes/BinaryIO) 

131 if isinstance(data, (bytes, BinaryIO)) or hasattr(data, "read"): 

132 is_valid, error = self.validate_upload(data, filename, options) # type: ignore[arg-type] 

133 if not is_valid: 

134 return Err(DataError(message=error or "Validation failed")) 

135 

136 # Generate path 

137 resource_type = options.resource_type if options else None 

138 resource_id = options.resource_id if options else None 

139 

140 from lexigram.admin.services.storage.utils import generate_upload_path 

141 

142 path = generate_upload_path( 

143 filename, 

144 resource_type=resource_type, 

145 resource_id=resource_id, 

146 base_path=self.config.base_path, 

147 ) 

148 

149 try: 

150 # Compute SHA-256 hash for bytes content 

151 sha256_hash: str | None = None 

152 if isinstance(data, bytes): 

153 sha256_hash = hashlib.sha256(data).hexdigest() 

154 

155 # Upload to storage 

156 upload_options = options.to_storage_options() if options else None 

157 if ( 

158 sha256_hash 

159 and upload_options is not None 

160 and hasattr(upload_options, "metadata") 

161 ): 

162 upload_options.metadata = { # type: ignore[misc] 

163 **(upload_options.metadata or {}), 

164 "sha256": sha256_hash, 

165 } 

166 result = await self._store.upload(path, data, upload_options) # type: ignore[arg-type] 

167 

168 # Convert to AdminFileInfo if needed 

169 file_info: AdminFileInfo 

170 if isinstance(result, AdminFileInfo): 

171 file_info = result 

172 elif FileInfo and isinstance(result, FileInfo): # type: ignore[truthy-function] 

173 file_info = AdminFileInfo( 

174 path=result.path, 

175 size=result.size, 

176 content_type=result.content_type, 

177 last_modified=result.last_modified, 

178 etag=result.etag, 

179 metadata=result.metadata, 

180 ) 

181 else: 

182 file_info = AdminFileInfo( 

183 path=path, 

184 size=len(data) if isinstance(data, bytes) else 0, 

185 content_type=get_content_type(filename), 

186 last_modified=datetime.now(UTC), 

187 ) 

188 

189 # Add admin metadata 

190 file_info.uploaded_by = uploaded_by 

191 file_info.resource_type = resource_type 

192 file_info.resource_id = resource_id 

193 

194 return Ok(file_info) 

195 except (RuntimeError, OSError, ConnectionError) as e: 

196 return Err(DataError(message=str(e), original_error=e)) 

197 

198 async def download(self, path: str) -> bytes: 

199 """Download file content. 

200 

201 Args: 

202 path: Storage path 

203 

204 Returns: 

205 File content as bytes 

206 """ 

207 return await self._store.download(path) 

208 

209 async def delete(self, path: str) -> bool: 

210 """Delete a file. 

211 

212 Args: 

213 path: Storage path 

214 

215 Returns: 

216 True if deleted 

217 """ 

218 try: 

219 await self._store.delete(path) 

220 return True 

221 except (RuntimeError, OSError): 

222 return False 

223 

224 async def exists(self, path: str) -> bool: 

225 """Check if file exists. 

226 

227 Args: 

228 path: Storage path 

229 

230 Returns: 

231 True if exists 

232 """ 

233 return await self._store.exists(path) 

234 

235 async def get_download_url( 

236 self, 

237 path: str, 

238 expires_in: int | None = None, 

239 ) -> str: 

240 """Get presigned download URL. 

241 

242 Args: 

243 path: Storage path 

244 expires_in: URL expiry in seconds 

245 

246 Returns: 

247 Presigned URL 

248 """ 

249 from datetime import timedelta 

250 

251 expires_seconds = expires_in or self.config.presigned_url_expiry 

252 return await self._store.get_presigned_url( 

253 path, 

254 expires_in=timedelta(seconds=expires_seconds), 

255 method="GET", 

256 ) 

257 

258 async def get_upload_url( 

259 self, 

260 filename: str, 

261 content_type: str | None = None, 

262 expires_in: int | None = None, 

263 resource_type: str | None = None, 

264 resource_id: Any = None, 

265 ) -> tuple[str, str]: 

266 """Get presigned upload URL for direct uploads. 

267 

268 Allows clients to upload directly to storage without 

269 going through the server. 

270 

271 Args: 

272 filename: Target filename 

273 

274 Returns: 

275 Tuple of (presigned_url, storage_path) 

276 """ 

277 from datetime import timedelta 

278 

279 expires_seconds = expires_in or self.config.presigned_url_expiry 

280 

281 from lexigram.admin.services.storage.utils import generate_upload_path 

282 

283 path = generate_upload_path( 

284 filename, 

285 resource_type=resource_type, 

286 resource_id=resource_id, 

287 base_path=self.config.base_path, 

288 ) 

289 

290 url = await self._store.get_presigned_url( 

291 path, 

292 expires_in=timedelta(seconds=expires_seconds), 

293 method="PUT", 

294 ) 

295 

296 return url, path