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

122 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-16 16:37 +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 

21import shutil 

22import tempfile 

23from collections.abc import Callable, Iterator 

24from contextlib import contextmanager 

25from pathlib import Path 

26from typing import TYPE_CHECKING, Any, Literal 

27 

28# Local imports 

29from ..adapters import UploaderFactory 

30from ..shared._constants import RELEASE_SUBDIR, TUF_PUBLIC_DIRS, UPDATE_SUBDIR 

31from ..shared.exceptions import UploadError 

32from ..utils.validators import validate_upload_structure 

33from .tuf_service import WITHDRAWN_FILE, TufService 

34 

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

36 from .._types import UploaderPort 

37 from ..shared._compiler_config import CompilerConfig 

38 

39# /////////////////////////////////////////////////////////////// 

40# TYPE ALIASES 

41# /////////////////////////////////////////////////////////////// 

42 

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

44 

45# /////////////////////////////////////////////////////////////// 

46# CONSTANTS 

47# /////////////////////////////////////////////////////////////// 

48 

49# Default keystore name, excluded from staging whatever its position. 

50DEFAULT_KEYSTORE_DIRNAME = "keystore" 

51 

52# /////////////////////////////////////////////////////////////// 

53# CLASSES 

54# /////////////////////////////////////////////////////////////// 

55 

56 

57class UploaderService: 

58 """ 

59 Upload orchestration service. 

60 

61 Orchestrates file and directory uploads using different upload backends. 

62 Handles upload type selection, validation, and execution. 

63 

64 Example: 

65 >>> service = UploaderService() 

66 >>> service.upload( 

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

68 ... upload_type="disk", 

69 ... destination="releases/", 

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

71 ... ) 

72 """ 

73 

74 # //////////////////////////////////////////////// 

75 # UPLOAD METHODS 

76 # //////////////////////////////////////////////// 

77 

78 @staticmethod 

79 def upload( 

80 source_path: Path, 

81 upload_type: UploadType, 

82 destination: str, 

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

84 ) -> None: 

85 """ 

86 Upload a file or directory to the specified destination. 

87 

88 Args: 

89 source_path: Path to the source file or directory 

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

91 destination: Destination path or URL 

92 upload_config: Additional uploader configuration options 

93 

94 Raises: 

95 UploadError: If upload fails or upload type is invalid 

96 

97 Example: 

98 >>> UploaderService.upload( 

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

100 ... "disk", 

101 ... "releases/", 

102 ... {"overwrite": True} 

103 ... ) 

104 """ 

105 try: 

106 # Validate upload type 

107 if not validate_upload_structure(upload_type): 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true

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

109 

110 # Copy: the caller's dict is reused for the following uploads, 

111 # an in-place mutation would contaminate the next backend. 

112 config = dict(upload_config or {}) 

113 if upload_type == "disk": 

114 config["destination_path"] = destination 

115 elif upload_type == "server": 115 ↛ 120line 115 didn't jump to line 120 because the condition on line 115 was always true

116 config["server_url"] = destination 

117 # r2: bucket comes from upload_config, destination = object prefix 

118 

119 # Create uploader and perform upload 

120 uploader: UploaderPort = UploaderFactory.create_uploader( 

121 upload_type, config 

122 ) 

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

124 except UploadError: 

125 raise 

126 except Exception as e: 

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

128 

129 @staticmethod 

130 def download( 

131 remote_source: str, 

132 upload_type: UploadType, 

133 destination_local: Path, 

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

135 ) -> None: 

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

137 

138 Args: 

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

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

141 destination_local: Local directory to populate. 

142 upload_config: Additional uploader configuration options. 

143 

144 Raises: 

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

146 """ 

147 try: 

148 uploader: UploaderPort = UploaderFactory.create_uploader( 

149 upload_type, upload_config or {} 

150 ) 

151 uploader.download(remote_source, destination_local) 

152 except UploadError: 

153 raise 

154 except Exception as e: 

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

156 

157 @staticmethod 

158 def upload_release( 

159 config: CompilerConfig, 

160 repo_dir: Path, 

161 release_root: Path | None, 

162 destination: str | None = None, 

163 repo_destination: str | None = None, 

164 release_destination: str | None = None, 

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

166 ) -> None: 

167 """Sequential double upload: TUF tree then installer zip. 

168 

169 Step 1 — upload the TUF tree to ``<dest>/update/`` (or the R2 prefix). 

170 Step 2 — upload the installer zip to ``<dest>/release/`` (skipped on R2). 

171 

172 Note: The double upload is not atomic. If the zip upload fails, the 

173 TUF repo is already online. On failure, run upload() again to 

174 resume. 

175 

176 Args: 

177 config: CompilerConfig holding the destinations and R2 options. 

178 repo_dir: Local directory of the TUF repo. 

179 release_root: Local directory of the installer zip (None on R2). 

180 destination: Shared override for both destinations. 

181 repo_destination: Override for ``config.repo_destination``. 

182 release_destination: Override for ``config.release_destination``. 

183 upload_config: Extra options passed to the uploaders. 

184 

185 Raises: 

186 UploadError: If an upload fails. 

187 """ 

188 repo_dest = repo_destination or config.repo_destination 

189 rel_dest = release_destination or config.release_destination 

190 

191 UploaderService.upload_tuf_repo( 

192 config, repo_dir, repo_dest, destination, upload_config 

193 ) 

194 

195 if release_root is not None: 

196 UploaderService.upload_release_zip( 

197 config, release_root, rel_dest, destination, upload_config 

198 ) 

199 

200 @staticmethod 

201 def upload_tuf_repo( 

202 config: CompilerConfig, 

203 repo_dir: Path, 

204 repo_dest: str, 

205 destination: str | None, 

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

207 ) -> None: 

208 """Upload the public part of the TUF tree to the configured destination. 

209 

210 Only ``metadata/``, ``targets/`` and ``withdrawn.json`` are published, 

211 from a temporary copy (see ``staged_tuf_tree``): the private keys 

212 stored under ``repo_dir`` are never transferred. 

213 """ 

214 try: 

215 with UploaderService.staged_tuf_tree( 

216 repo_dir, keys_dir=TufService.keys_dir(config) 

217 ) as staged: 

218 UploaderService._upload_tuf_tree( 

219 config, staged, repo_dest, destination, upload_config 

220 ) 

221 except UploadError as e: 

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

223 

224 @staticmethod 

225 @contextmanager 

226 def staged_tuf_tree( 

227 repo_dir: Path, *, keys_dir: Path | None = None 

228 ) -> Iterator[Path]: 

229 """Copy the public files of the TUF tree into a temporary directory. 

230 

231 The layout is preserved (``metadata/``, ``targets/``, 

232 ``withdrawn.json`` at the root); the directory is removed on exit. 

233 The keystore is always excluded from the copy, even when it sits 

234 under ``metadata/`` or ``targets/``. 

235 

236 Args: 

237 repo_dir: Root of the local TUF tree. 

238 keys_dir: Private keystore to exclude, on top of any directory 

239 named ``keystore``. 

240 

241 Raises: 

242 UploadError: If ``repo_dir/metadata`` is missing (publishing an 

243 empty tree would overwrite the remote channel) or if the copy 

244 fails. 

245 """ 

246 if not (repo_dir / "metadata").is_dir(): 

247 raise UploadError( 

248 f"TUF tree missing: {repo_dir / 'metadata'} not found. " 

249 "Run `ezcompiler tuf init` first, then the build." 

250 ) 

251 with tempfile.TemporaryDirectory(prefix="ezcompiler-tuf-") as tmp: 

252 staging = Path(tmp) 

253 try: 

254 ignore = UploaderService._ignore_private_keys(keys_dir) 

255 for name in TUF_PUBLIC_DIRS: 

256 if (repo_dir / name).is_dir(): 

257 shutil.copytree(repo_dir / name, staging / name, ignore=ignore) 

258 withdrawn = repo_dir / WITHDRAWN_FILE 

259 if withdrawn.is_file(): 259 ↛ 260line 259 didn't jump to line 260 because the condition on line 259 was never true

260 shutil.copy2(withdrawn, staging / WITHDRAWN_FILE) 

261 except OSError as e: 

262 raise UploadError(f"Copie de l'arbre TUF impossible : {e}") from e 

263 yield staging 

264 

265 @staticmethod 

266 def _ignore_private_keys( 

267 keys_dir: Path | None, 

268 ) -> Callable[[str, list[str]], set[str]]: 

269 """Build the ``ignore`` filter for ``shutil.copytree``. 

270 

271 Args: 

272 keys_dir: Configured private keystore, when known. 

273 

274 Returns: 

275 Callable: Filter excluding the configured keystore as well as any 

276 directory named ``keystore``. 

277 """ 

278 resolved = keys_dir.expanduser().resolve() if keys_dir else None 

279 

280 def ignore(src: str, names: list[str]) -> set[str]: 

281 excluded: set[str] = set() 

282 for name in names: 

283 if name == DEFAULT_KEYSTORE_DIRNAME: 283 ↛ 284line 283 didn't jump to line 284 because the condition on line 283 was never true

284 excluded.add(name) 

285 continue 

286 if resolved is None: 

287 continue 

288 candidate = (Path(src) / name).resolve() 

289 if candidate == resolved or resolved in candidate.parents: 289 ↛ 290line 289 didn't jump to line 290 because the condition on line 289 was never true

290 excluded.add(name) 

291 return excluded 

292 

293 return ignore 

294 

295 @staticmethod 

296 def _upload_tuf_tree( 

297 config: CompilerConfig, 

298 staged_dir: Path, 

299 repo_dest: str, 

300 destination: str | None, 

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

302 ) -> None: 

303 """Route an already-filtered TUF tree to the configured backend.""" 

304 if repo_dest == "r2": 

305 endpoint = config.repo_endpoint 

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

307 UploaderService.upload( 

308 source_path=staged_dir, 

309 upload_type="r2", 

310 destination=prefix, 

311 upload_config={"bucket": bucket}, 

312 ) 

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

314 base = destination or config.resolved_repo_destination or "" 

315 UploaderService.upload( 

316 source_path=staged_dir, 

317 upload_type="server", 

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

319 upload_config=upload_config, 

320 ) 

321 else: # disk (default) 

322 base = destination or config.resolved_repo_destination or "" 

323 UploaderService.upload( 

324 source_path=staged_dir, 

325 upload_type="disk", 

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

327 upload_config=upload_config, 

328 ) 

329 

330 @staticmethod 

331 def upload_release_zip( 

332 config: CompilerConfig, 

333 release_root: Path, 

334 rel_dest: str, 

335 destination: str | None, 

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

337 ) -> None: 

338 """Upload the installer zip to the configured destination.""" 

339 try: 

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

341 endpoint = config.release_endpoint 

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

343 UploaderService.upload( 

344 source_path=release_root, 

345 upload_type="r2", 

346 destination=prefix, 

347 upload_config={"bucket": bucket}, 

348 ) 

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

350 base = destination or config.resolved_release_destination or "" 

351 UploaderService.upload( 

352 source_path=release_root, 

353 upload_type="server", 

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

355 upload_config=upload_config, 

356 ) 

357 else: # disk (default) 

358 base = destination or config.resolved_release_destination or "" 

359 UploaderService.upload( 

360 source_path=release_root, 

361 upload_type="disk", 

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

363 upload_config=upload_config, 

364 ) 

365 except UploadError as e: 

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

367 

368 # //////////////////////////////////////////////// 

369 # UTILITY METHODS 

370 # //////////////////////////////////////////////// 

371 

372 @staticmethod 

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

374 """ 

375 Get list of supported upload types. 

376 

377 Returns: 

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

379 

380 Example: 

381 >>> types = UploaderService.get_supported_types() 

382 >>> print(types) 

383 ['disk', 'server'] 

384 """ 

385 return UploaderFactory.get_supported_types() 

386 

387 @staticmethod 

388 def validate_upload_config( 

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

390 ) -> bool: 

391 """ 

392 Validate configuration for a specific upload type. 

393 

394 Args: 

395 upload_type: Type of uploader to validate 

396 config: Configuration to validate (default: None) 

397 

398 Returns: 

399 bool: True if configuration is valid, False otherwise 

400 

401 Example: 

402 >>> is_valid = UploaderService.validate_upload_config( 

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

404 ... ) 

405 """ 

406 return UploaderFactory.validate_config(upload_type, config)