Coverage for src/gitlabracadabra/packages/destination.py: 85%
82 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-14 23:10 +0200
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-14 23:10 +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:
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 uploading %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 # Test source exists
127 if not self._source_package_file_exists(source, package_file):
128 return
130 # Test destination exists
131 if self._destination_package_file_exists(package_file):
132 return
134 # Test dry run
135 if self._dry_run(package_file, dry_run=dry_run):
136 return
138 # Upload
139 self._upload_package_file(source, package_file)
141 def upload_method(
142 self,
143 package_file: PackageFile, # noqa: ARG002
144 ) -> str:
145 """Get upload HTTP method.
147 Args:
148 package_file: Source package file.
150 Returns:
151 The upload method.
152 """
153 return "PUT"
155 def head_url(self, package_file: PackageFile) -> str:
156 """Get URL to test existence of destination package file with a HEAD request.
158 Args:
159 package_file: Source package file.
161 Raises:
162 NotImplementedError: This is an abstract method.
163 """
164 raise NotImplementedError
166 def upload_url(self, package_file: PackageFile) -> str:
167 """Get URL to upload to.
169 Args:
170 package_file: Source package file.
172 Returns:
173 The upload URL.
174 """
175 return self.head_url(package_file)
177 def files_key(
178 self,
179 package_file: PackageFile, # noqa: ARG002
180 ) -> str | None:
181 """Get files key, to upload to. If None, uploaded as body.
183 Args:
184 package_file: Source package file.
186 Returns:
187 The files key, or None.
188 """
189 return None
191 def _source_package_file_exists(self, source: Source, package_file: PackageFile) -> bool:
192 source_exists_response = source.session.request(
193 "HEAD",
194 package_file.url,
195 )
196 if source_exists_response.status_code == codes["ok"]:
197 return True
198 if source_exists_response.status_code == codes["not_found"]: 198 ↛ 209line 198 didn't jump to line 209 because the condition on line 198 was always true
199 logger.warning(
200 '%sNOT uploading %s package file "%s" from "%s" version %s (%s): source not found',
201 self._log_prefix,
202 package_file.package_type,
203 package_file.file_name,
204 package_file.package_name,
205 package_file.package_version,
206 package_file.url,
207 )
208 return False
209 logger.warning(
210 '%sNOT uploading %s package file "%s" from "%s" version %s (%s): received %i %s with HEAD method on source',
211 self._log_prefix,
212 package_file.package_type,
213 package_file.file_name,
214 package_file.package_name,
215 package_file.package_version,
216 package_file.url,
217 source_exists_response.status_code,
218 source_exists_response.reason,
219 )
220 return False
222 def _destination_package_file_exists(self, package_file: PackageFile) -> bool:
223 head_url = self.head_url(package_file)
224 destination_exists_response = self.session.request(
225 "HEAD",
226 head_url,
227 )
228 if destination_exists_response.status_code == codes["ok"]:
229 return True
230 if destination_exists_response.status_code == codes["not_found"]: 230 ↛ 232line 230 didn't jump to line 232 because the condition on line 230 was always true
231 return False
232 logger.warning(
233 '%sUnexpected HTTP status for %s package file "%s" from "%s" version %s (%s): received %i %s with HEAD method on destination',
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 head_url,
240 destination_exists_response.status_code,
241 destination_exists_response.reason,
242 )
243 return False
245 def _dry_run(self, package_file: PackageFile, *, dry_run: bool) -> bool:
246 if dry_run:
247 logger.info(
248 '%sNOT uploading %s package file "%s" from "%s" version %s (%s): Dry run',
249 self._log_prefix,
250 package_file.package_type,
251 package_file.file_name,
252 package_file.package_name,
253 package_file.package_version,
254 package_file.url,
255 )
256 return dry_run
258 def _upload_package_file(self, source: Source, package_file: PackageFile) -> None:
259 upload_method = self.upload_method(package_file)
260 upload_url = self.upload_url(package_file)
261 files_key = self.files_key(package_file)
263 logger.info(
264 '%sUploading %s package file "%s" from "%s" version %s (%s)',
265 self._log_prefix,
266 package_file.package_type,
267 package_file.file_name,
268 package_file.package_name,
269 package_file.package_version,
270 package_file.url,
271 )
272 download_response = source.session.request(
273 "GET",
274 package_file.url,
275 stream=True,
276 headers={
277 "Accept-Encoding": "*",
278 },
279 )
281 if files_key:
282 upload_response = self.session.request(
283 upload_method,
284 upload_url,
285 files={files_key: Stream(download_response)}, # type: ignore
286 )
287 else:
288 upload_response = self.session.request(
289 upload_method,
290 upload_url,
291 data=Stream(download_response),
292 )
293 if upload_response.status_code not in {codes["created"], codes["accepted"]}: 293 ↛ 294line 293 didn't jump to line 294 because the condition on line 293 was never true
294 logger.warning(
295 '%sError uploading %s package file "%s" from "%s" version %s (%s): %s',
296 self._log_prefix,
297 package_file.package_type,
298 package_file.file_name,
299 package_file.package_name,
300 package_file.package_version,
301 upload_url,
302 upload_response.content,
303 )