Coverage for src/gitlabracadabra/packages/destination.py: 83%

94 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-23 06:44 +0200

1# 

2# Copyright (C) 2019-2025 Mathieu Parent <math.parent@gmail.com> 

3# 

4# This program is free software: you can redistribute it and/or modify 

5# it under the terms of the GNU Lesser General Public License as published by 

6# the Free Software Foundation, either version 3 of the License, or 

7# (at your option) any later version. 

8# 

9# This program is distributed in the hope that it will be useful, 

10# but WITHOUT ANY WARRANTY; without even the implied warranty of 

11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

12# GNU Lesser General Public License for more details. 

13# 

14# You should have received a copy of the GNU Lesser General Public License 

15# along with this program. If not, see <http://www.gnu.org/licenses/>. 

16 

17from __future__ import annotations 

18 

19from logging import getLogger 

20from typing import TYPE_CHECKING 

21 

22from requests import RequestException, codes 

23 

24from gitlabracadabra import __version__ as gitlabracadabra_version 

25from gitlabracadabra.packages.stream import Stream 

26from gitlabracadabra.session import Session 

27 

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

29 from gitlabracadabra.packages.package_file import PackageFile 

30 from gitlabracadabra.packages.source import Source 

31 

32logger = getLogger(__name__) 

33 

34 

35class Destination: 

36 """Destination package repository.""" 

37 

38 def __init__( 

39 self, 

40 *, 

41 log_prefix: str = "", 

42 ) -> None: 

43 """Initialize Destination repository. 

44 

45 Args: 

46 log_prefix: Log prefix. 

47 """ 

48 self._log_prefix = log_prefix 

49 self.session = Session() 

50 self.session.headers["User-Agent"] = f"gitlabracadabra/{gitlabracadabra_version}" 

51 

52 def __del__(self) -> None: 

53 """Destroy a connection.""" 

54 self.session.close() 

55 

56 def import_source(self, source: Source, *, dry_run: bool) -> None: 

57 """Import package files from Source. 

58 

59 Args: 

60 source: Source repository. 

61 dry_run: Dry run. 

62 """ 

63 try: 

64 for package_file in source.package_files(self): 

65 self.try_import_package_file(source, package_file, dry_run=dry_run) 

66 except RequestException as err: 

67 if err.request: 67 ↛ 77line 67 didn't jump to line 77 because the condition on line 67 was always true

68 logger.warning( 

69 "%sError retrieving package files list from %s (%s %s): %s", 

70 self._log_prefix, 

71 str(source), 

72 err.request.method, 

73 err.request.url, 

74 repr(err), 

75 ) 

76 else: 

77 logger.warning( 

78 "%sError retrieving package files list from %s: %s", 

79 self._log_prefix, 

80 str(source), 

81 repr(err), 

82 ) 

83 

84 def try_import_package_file(self, source: Source, package_file: PackageFile, *, dry_run: bool) -> None: 

85 """Try to import one package file, and catch RequestExceptions. 

86 

87 Args: 

88 source: Source repository. 

89 package_file: Source package file. 

90 dry_run: Dry run. 

91 """ 

92 try: 

93 self.import_package_file(source, package_file, dry_run=dry_run) 

94 except RequestException as err: 

95 if err.request: 95 ↛ 108line 95 didn't jump to line 108 because the condition on line 95 was always true

96 logger.warning( 

97 '%sError processing %s package file "%s" from "%s" version %s (%s %s): %s', 

98 self._log_prefix, 

99 package_file.package_type, 

100 package_file.file_name, 

101 package_file.package_name, 

102 package_file.package_version, 

103 err.request.method, 

104 err.request.url, 

105 repr(err), 

106 ) 

107 else: 

108 logger.warning( 

109 '%sError uploading %s package file "%s" from "%s" version %s: %s', 

110 self._log_prefix, 

111 package_file.package_type, 

112 package_file.file_name, 

113 package_file.package_name, 

114 package_file.package_version, 

115 repr(err), 

116 ) 

117 

118 def import_package_file(self, source: Source, package_file: PackageFile, *, dry_run: bool) -> None: 

119 """Import one package file. 

120 

121 Args: 

122 source: Source repository. 

123 package_file: Source package file. 

124 dry_run: Dry run. 

125 """ 

126 if package_file.delete: 

127 if package_file.force or self._destination_package_file_exists(package_file): 127 ↛ 147line 127 didn't jump to line 147 because the condition on line 127 was always true

128 if dry_run: 128 ↛ 129line 128 didn't jump to line 129 because the condition on line 128 was never true

129 logger.info( 

130 '%sNOT deleting %s package file "%s" from "%s" version %s: Dry run', 

131 self._log_prefix, 

132 package_file.package_type, 

133 package_file.file_name, 

134 package_file.package_name, 

135 package_file.package_version, 

136 ) 

137 return 

138 logger.info( 

139 '%Deleting %s package file "%s" from "%s" version %s', 

140 self._log_prefix, 

141 package_file.package_type, 

142 package_file.file_name, 

143 package_file.package_name, 

144 package_file.package_version, 

145 ) 

146 self.delete_package_file(package_file) 

147 return 

148 

149 # Test source exists 

150 if not package_file.force and not self._source_package_file_exists(source, package_file): 

151 return 

152 

153 # Force or test destination exists 

154 if not package_file.force and self._destination_package_file_exists(package_file): 

155 return 

156 

157 # Test dry run 

158 if self._dry_run(package_file, dry_run=dry_run): 

159 return 

160 

161 # Upload 

162 self._upload_package_file(source, package_file) 

163 

164 def upload_method( 

165 self, 

166 package_file: PackageFile, # noqa: ARG002 

167 ) -> str: 

168 """Get upload HTTP method. 

169 

170 Args: 

171 package_file: Source package file. 

172 

173 Returns: 

174 The upload method. 

175 """ 

176 return "PUT" 

177 

178 def get_url(self, package_file: PackageFile) -> str: 

179 """Get URL to test existence of destination package file with a HEAD request. 

180 

181 Args: 

182 package_file: Source package file. 

183 

184 Raises: 

185 NotImplementedError: This is an abstract method. 

186 """ 

187 raise NotImplementedError 

188 

189 def upload_url(self, package_file: PackageFile) -> str: 

190 """Get URL to upload to. 

191 

192 Args: 

193 package_file: Source package file. 

194 

195 Returns: 

196 The upload URL. 

197 """ 

198 return self.get_url(package_file) 

199 

200 def files_key( 

201 self, 

202 package_file: PackageFile, # noqa: ARG002 

203 ) -> str | None: 

204 """Get files key, to upload to. If None, uploaded as body. 

205 

206 Args: 

207 package_file: Source package file. 

208 

209 Returns: 

210 The files key, or None. 

211 """ 

212 return None 

213 

214 def _source_package_file_exists(self, source: Source, package_file: PackageFile) -> bool: 

215 source_exists_response = source.session.request( 

216 "HEAD", 

217 package_file.url, 

218 ) 

219 if source_exists_response.status_code == codes["ok"]: 

220 return True 

221 if source_exists_response.status_code == codes["not_found"]: 221 ↛ 232line 221 didn't jump to line 232 because the condition on line 221 was always true

222 logger.warning( 

223 '%sNOT uploading %s package file "%s" from "%s" version %s (%s): source not found', 

224 self._log_prefix, 

225 package_file.package_type, 

226 package_file.file_name, 

227 package_file.package_name, 

228 package_file.package_version, 

229 package_file.url, 

230 ) 

231 return False 

232 logger.warning( 

233 '%sNOT uploading %s package file "%s" from "%s" version %s (%s): received %i %s with HEAD method on source', 

234 self._log_prefix, 

235 package_file.package_type, 

236 package_file.file_name, 

237 package_file.package_name, 

238 package_file.package_version, 

239 package_file.url, 

240 source_exists_response.status_code, 

241 source_exists_response.reason, 

242 ) 

243 return False 

244 

245 def _destination_package_file_exists(self, package_file: PackageFile) -> bool: 

246 head_url = self.get_url(package_file) 

247 destination_exists_response = self.session.request( 

248 "HEAD", 

249 head_url, 

250 ) 

251 if destination_exists_response.status_code == codes["ok"]: 

252 return True 

253 if destination_exists_response.status_code == codes["not_found"]: 253 ↛ 255line 253 didn't jump to line 255 because the condition on line 253 was always true

254 return False 

255 logger.warning( 

256 '%sUnexpected HTTP status for %s package file "%s" from "%s" version %s (%s): received %i %s with HEAD method on destination', 

257 self._log_prefix, 

258 package_file.package_type, 

259 package_file.file_name, 

260 package_file.package_name, 

261 package_file.package_version, 

262 head_url, 

263 destination_exists_response.status_code, 

264 destination_exists_response.reason, 

265 ) 

266 return False 

267 

268 def cache_project_package_package_files(self, package_type: str, package_name: str, package_version: str) -> None: 

269 raise NotImplementedError 

270 

271 def delete_package_file(self, package_file: PackageFile) -> None: 

272 raise NotImplementedError 

273 

274 def _dry_run(self, package_file: PackageFile, *, dry_run: bool) -> bool: 

275 if dry_run: 

276 logger.info( 

277 '%sNOT uploading %s package file "%s" from "%s" version %s (%s): Dry run', 

278 self._log_prefix, 

279 package_file.package_type, 

280 package_file.file_name, 

281 package_file.package_name, 

282 package_file.package_version, 

283 package_file.url, 

284 ) 

285 return dry_run 

286 

287 def _upload_package_file(self, source: Source, package_file: PackageFile) -> None: 

288 upload_method = self.upload_method(package_file) 

289 upload_url = self.upload_url(package_file) 

290 files_key = self.files_key(package_file) 

291 

292 logger.info( 

293 '%sUploading %s package file "%s" from "%s" version %s (%s)', 

294 self._log_prefix, 

295 package_file.package_type, 

296 package_file.file_name, 

297 package_file.package_name, 

298 package_file.package_version, 

299 package_file.url, 

300 ) 

301 download_response = source.session.request( 

302 "GET", 

303 package_file.url, 

304 stream=True, 

305 headers={ 

306 "Accept-Encoding": "*", 

307 }, 

308 ) 

309 

310 if files_key: 

311 upload_response = self.session.request( 

312 upload_method, 

313 upload_url, 

314 files={files_key: Stream(download_response)}, # type: ignore 

315 ) 

316 else: 

317 upload_response = self.session.request( 

318 upload_method, 

319 upload_url, 

320 data=Stream(download_response), 

321 ) 

322 if upload_response.status_code not in {codes["created"], codes["accepted"]}: 322 ↛ 323line 322 didn't jump to line 323 because the condition on line 322 was never true

323 logger.warning( 

324 '%sError uploading %s package file "%s" from "%s" version %s (%s): %s', 

325 self._log_prefix, 

326 package_file.package_type, 

327 package_file.file_name, 

328 package_file.package_name, 

329 package_file.package_version, 

330 upload_url, 

331 upload_response.content, 

332 )