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

17 from __future__ import annotations

18

19 from logging import getLogger

20 from typing import TYPE_CHECKING

21

22 from requests import RequestException, codes

23

24 from gitlabracadabra import __version__ as gitlabracadabra_version

25 from gitlabracadabra.packages.stream import Stream

26 from gitlabracadabra.session import Session

27

28 if TYPE_CHECKING: 28 ↛ 29 line 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

32 logger = getLogger(__name__)

33

34

35 class 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 ↛ 77 line 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 ↛ 108 line 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 ↛ 147 line 127 didn't jump to line 147 because the condition on line 127 was always true

128 if dry_run: 128 ↛ 129 line 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 ↛ 232 line 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 ↛ 255 line 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 ↛ 323 line 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 )