Coverage for src/ezcompiler/services/uploader_service.py: 52.08%

78 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-04 11:22 +0000

1# /////////////////////////////////////////////////////////////// 

2# UPLOADER_SERVICE - Upload orchestration service 

3# Project: ezcompiler 

4# /////////////////////////////////////////////////////////////// 

5 

6""" 

7Uploader service - Upload orchestration service for EzCompiler. 

8 

9This module provides the UploaderService class that orchestrates file 

10and directory uploads using different upload backends (disk, server). 

11 

12Services layer can use WARNING and ERROR log levels. 

13""" 

14 

15from __future__ import annotations 

16 

17# /////////////////////////////////////////////////////////////// 

18# IMPORTS 

19# /////////////////////////////////////////////////////////////// 

20# Standard library imports 

21from pathlib import Path 

22from typing import TYPE_CHECKING, Any, Literal 

23 

24# Local imports 

25from ..adapters import UploaderFactory 

26from ..shared._constants import RELEASE_SUBDIR, UPDATE_SUBDIR 

27from ..shared.exceptions import UploadError 

28from ..utils.validators import validate_upload_structure 

29 

30if TYPE_CHECKING: 30 ↛ 31line 30 didn't jump to line 31 because the condition on line 30 was never true

31 from .._types import UploaderPort 

32 from ..shared._compiler_config import CompilerConfig 

33 

34# /////////////////////////////////////////////////////////////// 

35# TYPE ALIASES 

36# /////////////////////////////////////////////////////////////// 

37 

38UploadType = Literal["disk", "server", "r2"] 

39 

40# /////////////////////////////////////////////////////////////// 

41# CLASSES 

42# /////////////////////////////////////////////////////////////// 

43 

44 

45class UploaderService: 

46 """ 

47 Upload orchestration service. 

48 

49 Orchestrates file and directory uploads using different upload backends. 

50 Handles upload type selection, validation, and execution. 

51 

52 Example: 

53 >>> service = UploaderService() 

54 >>> service.upload( 

55 ... source_path=Path("dist.zip"), 

56 ... upload_type="disk", 

57 ... destination="releases/", 

58 ... upload_config={"overwrite": True} 

59 ... ) 

60 """ 

61 

62 # //////////////////////////////////////////////// 

63 # UPLOAD METHODS 

64 # //////////////////////////////////////////////// 

65 

66 @staticmethod 

67 def upload( 

68 source_path: Path, 

69 upload_type: UploadType, 

70 destination: str, 

71 upload_config: dict[str, Any] | None = None, 

72 ) -> None: 

73 """ 

74 Upload a file or directory to the specified destination. 

75 

76 Args: 

77 source_path: Path to the source file or directory 

78 upload_type: Type of upload ("disk" or "server") 

79 destination: Destination path or URL 

80 upload_config: Additional uploader configuration options 

81 

82 Raises: 

83 UploadError: If upload fails or upload type is invalid 

84 

85 Example: 

86 >>> UploaderService.upload( 

87 ... Path("dist.zip"), 

88 ... "disk", 

89 ... "releases/", 

90 ... {"overwrite": True} 

91 ... ) 

92 """ 

93 try: 

94 # Validate upload type 

95 if not validate_upload_structure(upload_type): 

96 raise UploadError(f"Invalid upload type: {upload_type}") 

97 

98 # Prepare uploader configuration 

99 config = upload_config or {} 

100 if upload_type == "disk": 

101 config["destination_path"] = destination 

102 elif upload_type == "server": 

103 config["server_url"] = destination 

104 # r2: bucket vient de upload_config, destination = préfixe objet 

105 

106 # Create uploader and perform upload 

107 uploader: UploaderPort = UploaderFactory.create_uploader( 

108 upload_type, config 

109 ) 

110 uploader.upload(source_path=source_path, destination=destination) 

111 except UploadError: 

112 raise 

113 except Exception as e: 

114 raise UploadError(f"Upload failed: {str(e)}") from e 

115 

116 @staticmethod 

117 def download( 

118 remote_source: str, 

119 upload_type: UploadType, 

120 destination_local: Path, 

121 upload_config: dict[str, Any] | None = None, 

122 ) -> None: 

123 """Download a remote tree into ``destination_local`` via the backend. 

124 

125 Args: 

126 remote_source: Remote source (path, URL or prefix) to fetch. 

127 upload_type: Backend type ("disk", "server" or "r2"). 

128 destination_local: Local directory to populate. 

129 upload_config: Additional uploader configuration options. 

130 

131 Raises: 

132 UploadError: If the download fails or the type is invalid. 

133 """ 

134 try: 

135 uploader: UploaderPort = UploaderFactory.create_uploader( 

136 upload_type, upload_config or {} 

137 ) 

138 uploader.download(remote_source, destination_local) 

139 except UploadError: 

140 raise 

141 except Exception as e: 

142 raise UploadError(f"Download failed: {str(e)}") from e 

143 

144 @staticmethod 

145 def upload_release( 

146 config: CompilerConfig, 

147 repo_dir: Path, 

148 release_root: Path | None, 

149 destination: str | None = None, 

150 repo_destination: str | None = None, 

151 release_destination: str | None = None, 

152 upload_config: dict[str, Any] | None = None, 

153 ) -> None: 

154 """Double upload séquentiel : arbre TUF puis zip installeur. 

155 

156 Étape 1 — upload arbre TUF vers ``<dest>/update/`` (ou préfixe R2). 

157 Étape 2 — upload zip installeur vers ``<dest>/release/`` (ignoré si R2). 

158 

159 Note: Le double-upload n'est pas atomique. Si l'upload du zip échoue, 

160 le repo TUF est déjà en ligne. En cas d'échec, ré-exécuter upload() 

161 pour reprendre. 

162 

163 Args: 

164 config: CompilerConfig contenant les destinations et options R2. 

165 repo_dir: Répertoire local du repo TUF. 

166 release_root: Répertoire local du zip installeur (None si R2). 

167 destination: Override commun pour les deux destinations. 

168 repo_destination: Override de ``config.repo_destination``. 

169 release_destination: Override de ``config.release_destination``. 

170 upload_config: Options supplémentaires passées aux uploaders. 

171 

172 Raises: 

173 UploadError: Si un upload échoue. 

174 """ 

175 repo_dest = repo_destination or config.repo_destination 

176 rel_dest = release_destination or config.release_destination 

177 

178 UploaderService._upload_tuf_repo( 

179 config, repo_dir, repo_dest, destination, upload_config 

180 ) 

181 

182 if release_root is not None: 

183 UploaderService._upload_release_zip( 

184 config, release_root, rel_dest, destination, upload_config 

185 ) 

186 

187 @staticmethod 

188 def _upload_tuf_repo( 

189 config: CompilerConfig, 

190 repo_dir: Path, 

191 repo_dest: str, 

192 destination: str | None, 

193 upload_config: dict[str, Any] | None, 

194 ) -> None: 

195 """Upload l'arbre TUF vers la destination configurée.""" 

196 try: 

197 if repo_dest == "r2": 

198 endpoint = config.repo_endpoint 

199 bucket, _, prefix = endpoint.partition("/") 

200 UploaderService.upload( 

201 source_path=repo_dir, 

202 upload_type="r2", 

203 destination=prefix, 

204 upload_config={"bucket": bucket}, 

205 ) 

206 elif repo_dest == "server": 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true

207 base = destination or config.resolved_repo_destination or "" 

208 UploaderService.upload( 

209 source_path=repo_dir, 

210 upload_type="server", 

211 destination=base.rstrip("/") + f"/{UPDATE_SUBDIR}", 

212 upload_config=upload_config, 

213 ) 

214 else: # disk (default) 

215 base = destination or config.resolved_repo_destination or "" 

216 UploaderService.upload( 

217 source_path=repo_dir, 

218 upload_type="disk", 

219 destination=str(Path(base) / UPDATE_SUBDIR), 

220 upload_config=upload_config, 

221 ) 

222 except UploadError as e: 

223 raise UploadError(f"TUF repo upload failed: {e}") from e 

224 

225 @staticmethod 

226 def _upload_release_zip( 

227 config: CompilerConfig, 

228 release_root: Path, 

229 rel_dest: str, 

230 destination: str | None, 

231 upload_config: dict[str, Any] | None, 

232 ) -> None: 

233 """Upload le zip installeur vers la destination configurée.""" 

234 try: 

235 if rel_dest == "r2": 235 ↛ 236line 235 didn't jump to line 236 because the condition on line 235 was never true

236 endpoint = config.release_endpoint 

237 bucket, _, prefix = endpoint.partition("/") 

238 UploaderService.upload( 

239 source_path=release_root, 

240 upload_type="r2", 

241 destination=prefix, 

242 upload_config={"bucket": bucket}, 

243 ) 

244 elif rel_dest == "server": 244 ↛ 245line 244 didn't jump to line 245 because the condition on line 244 was never true

245 base = destination or config.resolved_release_destination or "" 

246 UploaderService.upload( 

247 source_path=release_root, 

248 upload_type="server", 

249 destination=base.rstrip("/") + f"/{RELEASE_SUBDIR}", 

250 upload_config=upload_config, 

251 ) 

252 else: # disk (default) 

253 base = destination or config.resolved_release_destination or "" 

254 UploaderService.upload( 

255 source_path=release_root, 

256 upload_type="disk", 

257 destination=str(Path(base) / RELEASE_SUBDIR), 

258 upload_config=upload_config, 

259 ) 

260 except UploadError as e: 

261 raise UploadError(f"Release zip upload failed: {e}") from e 

262 

263 # //////////////////////////////////////////////// 

264 # UTILITY METHODS 

265 # //////////////////////////////////////////////// 

266 

267 @staticmethod 

268 def get_supported_types() -> list[str]: 

269 """ 

270 Get list of supported upload types. 

271 

272 Returns: 

273 list[str]: List of supported upload type names 

274 

275 Example: 

276 >>> types = UploaderService.get_supported_types() 

277 >>> print(types) 

278 ['disk', 'server'] 

279 """ 

280 return UploaderFactory.get_supported_types() 

281 

282 @staticmethod 

283 def validate_upload_config( 

284 upload_type: UploadType, config: dict[str, Any] | None = None 

285 ) -> bool: 

286 """ 

287 Validate configuration for a specific upload type. 

288 

289 Args: 

290 upload_type: Type of uploader to validate 

291 config: Configuration to validate (default: None) 

292 

293 Returns: 

294 bool: True if configuration is valid, False otherwise 

295 

296 Example: 

297 >>> is_valid = UploaderService.validate_upload_config( 

298 ... "disk", {"overwrite": True} 

299 ... ) 

300 """ 

301 return UploaderFactory.validate_config(upload_type, config)