Coverage for src/gitlabracadabra/packages/gitlab.py: 90%

62 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 

21from urllib.parse import quote 

22 

23from gitlab.v4.objects.packages import ProjectPackage, ProjectPackageFile 

24 

25from gitlabracadabra.packages.destination import Destination 

26 

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

28 from gitlabracadabra.gitlab.connection import GitlabConnection 

29 from gitlabracadabra.packages.package_file import PackageFile 

30 

31 

32HELM = "helm" 

33PYPI = "pypi" 

34 

35logger = getLogger(__name__) 

36 

37 

38class Gitlab(Destination): 

39 """Gitlab repository.""" 

40 

41 def __init__( 

42 self, 

43 *, 

44 connection: GitlabConnection, 

45 full_path: str, 

46 project_id: int, 

47 ) -> None: 

48 """Initialize Gitlab repository. 

49 

50 Args: 

51 connection: A Gitlab connection. 

52 full_path: Project full path. 

53 project_id: Project ID. 

54 """ 

55 super().__init__(log_prefix=f"[{full_path}] ") 

56 self._connection = connection 

57 self._full_path = full_path 

58 self._project_id = project_id 

59 self._connection.session_callback(self.session) 

60 # dict[ 

61 # tuple[package_name: str, package_version: str], 

62 # list[ProjectPackageFile] 

63 # ] 

64 self._project_package_package_files_cache: dict[tuple[str, str], list[ProjectPackageFile]] = {} 

65 

66 def upload_method(self, package_file: PackageFile) -> str: 

67 """Get upload HTTP method. 

68 

69 Args: 

70 package_file: Source package file. 

71 

72 Returns: 

73 The upload method. 

74 """ 

75 if package_file.package_type in {HELM, PYPI}: 

76 return "POST" 

77 

78 return super().upload_method(package_file) 

79 

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

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

82 

83 Args: 

84 package_file: Source package file. 

85 

86 Returns: 

87 An URL. 

88 

89 Raises: 

90 NotImplementedError: For unsupported package types. 

91 """ 

92 if package_file.package_type == "generic": 

93 return "{}/projects/{}/packages/generic/{}/{}/{}".format( 

94 self._connection.api_url, 

95 quote(self._full_path, safe=""), 

96 quote(package_file.package_name, safe=""), # [A-Za-z0-9\.\_\-\+]+ 

97 quote(package_file.package_version, safe=""), # (\.?[\w\+-]+\.?)+ 

98 quote(package_file.file_name, safe=""), # [A-Za-z0-9\.\_\-\+]+ 

99 ) 

100 if package_file.package_type == HELM: 

101 channel = package_file.metadata.get("channel") or "stable" 

102 file_name = f"{package_file.package_name}-{package_file.package_version}.tgz" 

103 return "{}/projects/{}/packages/helm/{}/charts/{}".format( 

104 self._connection.api_url, 

105 quote(self._full_path, safe=""), 

106 quote(channel, safe=""), 

107 quote(file_name, safe=""), 

108 ) 

109 if package_file.package_type == PYPI: 109 ↛ 117line 109 didn't jump to line 117 because the condition on line 109 was always true

110 return "{}/projects/{}/packages/pypi/files/{}/{}".format( 

111 self._connection.api_url, 

112 self._project_id, 

113 quote(package_file.metadata.get("sha256", ""), safe=""), 

114 quote(package_file.file_name, safe=""), 

115 ) 

116 

117 raise NotImplementedError 

118 

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

120 """Get URL to upload to. 

121 

122 Args: 

123 package_file: Source package file. 

124 

125 Returns: 

126 The upload URL. 

127 """ 

128 if package_file.package_type == HELM: 

129 channel = package_file.metadata.get("channel") or "stable" 

130 return "{}/projects/{}/packages/helm/api/{}/charts".format( 

131 self._connection.api_url, 

132 quote(self._full_path, safe=""), 

133 quote(channel, safe=""), 

134 ) 

135 if package_file.package_type == PYPI: 

136 return ( 

137 "{}/projects/{}/packages/pypi?" 

138 "requires_python={}&" 

139 "name={}&" 

140 "version={}&" 

141 "md5_digest={}&" 

142 "sha256_digest={}" 

143 ).format( 

144 self._connection.api_url, 

145 self._project_id, 

146 quote(package_file.metadata.get("requires-python", ""), safe=""), 

147 quote(package_file.package_name, safe=""), 

148 quote(package_file.package_version, safe=""), 

149 quote(package_file.metadata.get("md5", ""), safe=""), 

150 quote(package_file.metadata.get("sha256", ""), safe=""), 

151 ) 

152 

153 return super().upload_url(package_file) 

154 

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

156 if (package_name, package_version) not in self._project_package_package_files_cache: 

157 self._project_package_package_files_cache[(package_name, package_version)] = ( 

158 self._project_package_package_files(package_type, package_name, package_version) 

159 ) 

160 

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

162 # No DELETE endpoint for generic packages 

163 # https://gitlab.com/gitlab-org/gitlab/-/issues/536839 

164 self.cache_project_package_package_files( 

165 package_file.package_type, package_file.package_name, package_file.package_version 

166 ) 

167 for project_package_file in self._project_package_package_files_cache[ 

168 (package_file.package_name, package_file.package_version) 

169 ]: 

170 if project_package_file.file_name == package_file.file_name: 

171 project_package_file.delete() 

172 

173 def files_key(self, package_file: PackageFile) -> str | None: 

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

175 

176 Args: 

177 package_file: Source package file. 

178 

179 Returns: 

180 The files key, or None. 

181 """ 

182 if package_file.package_type == HELM: 

183 return "chart" 

184 if package_file.package_type == PYPI: 

185 return "content" 

186 

187 return super().files_key(package_file) 

188 

189 def _project_package_package_files( 

190 self, package_type: str, package_name: str, package_version: str 

191 ) -> list[ProjectPackageFile]: 

192 project = self._connection.pygitlab.projects.get(self._full_path, lazy=True) 

193 for project_package in project.packages.list( 193 ↛ 207line 193 didn't jump to line 207 because the loop on line 193 didn't complete

194 package_type=package_type, 

195 package_name=package_name, 

196 package_version=package_version, 

197 iterator=True, 

198 ): 

199 if not isinstance(project_package, ProjectPackage): 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true

200 raise TypeError 

201 return [ 

202 project_package_file 

203 for project_package_file in project_package.package_files.list(iterator=True) 

204 if isinstance(project_package_file, ProjectPackageFile) 

205 ] 

206 

207 return []