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
« 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/>.
17from __future__ import annotations
19from logging import getLogger
20from typing import TYPE_CHECKING
22from requests import RequestException, codes
24from gitlabracadabra import __version__ as gitlabracadabra_version
25from gitlabracadabra.packages.stream import Stream
26from gitlabracadabra.session import Session
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
32logger = getLogger(__name__)
35class Destination:
36 """Destination package repository."""
38 def __init__(
39 self,
40 *,
41 log_prefix: str = "",
42 ) -> None:
43 """Initialize Destination repository.
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}"
52 def __del__(self) -> None:
53 """Destroy a connection."""
54 self.session.close()
56 def import_source(self, source: Source, *, dry_run: bool) -> None:
57 """Import package files from Source.
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 )
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.
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 )
118 def import_package_file(self, source: Source, package_file: PackageFile, *, dry_run: bool) -> None:
119 """Import one package file.
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
149 # Test source exists
150 if not package_file.force and not self._source_package_file_exists(source, package_file):
151 return
153 # Force or test destination exists
154 if not package_file.force and self._destination_package_file_exists(package_file):
155 return
157 # Test dry run
158 if self._dry_run(package_file, dry_run=dry_run):
159 return
161 # Upload
162 self._upload_package_file(source, package_file)
164 def upload_method(
165 self,
166 package_file: PackageFile, # noqa: ARG002
167 ) -> str:
168 """Get upload HTTP method.
170 Args:
171 package_file: Source package file.
173 Returns:
174 The upload method.
175 """
176 return "PUT"
178 def get_url(self, package_file: PackageFile) -> str:
179 """Get URL to test existence of destination package file with a HEAD request.
181 Args:
182 package_file: Source package file.
184 Raises:
185 NotImplementedError: This is an abstract method.
186 """
187 raise NotImplementedError
189 def upload_url(self, package_file: PackageFile) -> str:
190 """Get URL to upload to.
192 Args:
193 package_file: Source package file.
195 Returns:
196 The upload URL.
197 """
198 return self.get_url(package_file)
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.
206 Args:
207 package_file: Source package file.
209 Returns:
210 The files key, or None.
211 """
212 return None
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
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
268 def cache_project_package_package_files(self, package_type: str, package_name: str, package_version: str) -> None:
269 raise NotImplementedError
271 def delete_package_file(self, package_file: PackageFile) -> None:
272 raise NotImplementedError
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
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)
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 )
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 )