Coverage for src/gitlabracadabra/objects/object.py: 67%

243 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-10 17:02 +0100

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 

19import logging 

20import re 

21from copy import deepcopy 

22from http import HTTPStatus 

23from typing import TYPE_CHECKING, ClassVar 

24 

25from gitlab.exceptions import GitlabCreateError, GitlabDeleteError, GitlabGetError, GitlabListError, GitlabUpdateError 

26from jsonschema.validators import validator_for 

27 

28from gitlabracadabra.gitlab.connections import GitlabConnections 

29 

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

31 from typing import Any 

32 

33 from gitlab import Gitlab 

34 from jsonschema.exceptions import ValidationError 

35 

36 from gitlabracadabra.gitlab.connection import GitlabConnection 

37 

38logger = logging.getLogger(__name__) 

39 

40 

41class GitLabracadabraObject: 

42 EXAMPLE_YAML_HEADER: ClassVar[str] = "myobject:\n" 

43 DOC: ClassVar[list[str]] = [] 

44 SCHEMA: ClassVar[dict[str, Any]] = { 

45 "$schema": "http://json-schema.org/draft-04/schema#", 

46 "title": "Object", 

47 "type": "object", 

48 "properties": {}, 

49 "additionalProperties": False, 

50 # 'required': [], 

51 } 

52 

53 # If not None, use find(FIND_PARAM=...) instead of get(...) 

54 FIND_PARAM: str | None = None 

55 

56 # If not None, set to id of the object on create 

57 CREATE_KEY: str | None = None 

58 

59 CREATE_PARAMS: ClassVar[list[str]] = [] 

60 

61 IGNORED_PARAMS: ClassVar[list[str]] = [] 

62 

63 def __init__(self, action_file, name, content): 

64 self._action_file = action_file 

65 self._name = name 

66 self._content = content 

67 self._just_created = False 

68 validator_class = validator_for(self.SCHEMA) 

69 validator_class.check_schema(self.SCHEMA) 

70 validator = validator_class(self.SCHEMA) 

71 self._errors: list[ValidationError] = sorted(validator.iter_errors(content), key=lambda e: e.path) 

72 self._gitlab_id = self._content.pop("gitlab_id", None) 

73 self._create_object = self._content.pop("create_object", None) 

74 self._delete_object = self._content.pop("delete_object", None) 

75 

76 """"web_url() 

77 

78 Returns the project's web URL. 

79 (Allows to mock the web URL). 

80 """ 

81 

82 def web_url(self) -> str: 

83 if isinstance(self._obj.web_url, str): 83 ↛ 85line 83 didn't jump to line 85 because the condition on line 83 was always true

84 return self._obj.web_url 

85 raise ValueError 

86 

87 @property 

88 def connection(self) -> GitlabConnection: 

89 return GitlabConnections().get_connection(self._gitlab_id) 

90 

91 @property 

92 def pygitlab(self) -> Gitlab: 

93 return self.connection.pygitlab 

94 

95 def errors(self) -> list[ValidationError]: 

96 return self._errors 

97 

98 """"type_name() 

99 

100 GitLabracadabraProject -> project. 

101 """ 

102 

103 @classmethod 

104 def type_name(cls) -> str: 

105 return cls.__name__[15:].lower() 

106 

107 """"type_name_plural() 

108 

109 GitLabracadabraProject -> projects. 

110 """ 

111 

112 @classmethod 

113 def _type_name_plural(cls): 

114 return cls.type_name() + "s" 

115 

116 """"_object_manager() 

117 

118 Return the python-gitlab Gilab object. 

119 """ 

120 

121 def _object_manager(self): 

122 return getattr(self.pygitlab, self._type_name_plural()) 

123 

124 """"_create() 

125 

126 Create the object. 

127 """ 

128 

129 def _create(self, *, dry_run: bool = False) -> Any: 

130 obj_manager = self._object_manager() # type:ignore 

131 namespace_manager = self.pygitlab.namespaces 

132 namespaces = self._name.split("/") 

133 object_path = namespaces.pop() 

134 create_params = { 

135 "path": object_path, 

136 } 

137 if self.CREATE_KEY: 137 ↛ 139line 137 didn't jump to line 139 because the condition on line 137 was always true

138 create_params[self.CREATE_KEY] = object_path 

139 for param_name in self.CREATE_PARAMS: 

140 if param_name in self._content: 

141 create_params[param_name] = self._content[param_name] 

142 if len(namespaces): 

143 try: 

144 parent_namespace = namespace_manager.get("/".join(namespaces)) 

145 except GitlabGetError as e: 

146 error_message = e.error_message 

147 if e.response_code == HTTPStatus.NOT_FOUND: 147 ↛ 149line 147 didn't jump to line 149 because the condition on line 147 was always true

148 error_message = "parent namespace not found" 

149 logger.error("[%s] NOT Creating %s (%s)", self._name, self.type_name(), error_message) 

150 return None 

151 if self.type_name() == "group": 

152 create_params["parent_id"] = parent_namespace.id 

153 else: 

154 create_params["namespace_id"] = parent_namespace.id 

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

156 logger.info("[%s] NOT Creating %s (dry-run)", self._name, self.type_name()) 

157 return None 

158 logger.info("[%s] Creating %s", self._name, self.type_name()) 

159 try: 

160 return obj_manager.create(create_params) 

161 except GitlabCreateError as e: 

162 logger.error("[%s] NOT Creating %s (%s)", self._name, self.type_name(), e.error_message) 

163 return None 

164 

165 """"_delete() 

166 

167 Delete the object. 

168 """ 

169 

170 def _delete(self, *, dry_run: bool = False) -> None: 

171 if self._obj is None: 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true

172 logger.debug("[%s] NOT Deleting %s (not found)", self._name, self.type_name()) 

173 elif dry_run: 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true

174 logger.info("[%s] NOT Deleting %s (dry-run)", self._name, self.type_name()) 

175 else: 

176 logger.info("[%s] Deleting %s", self._name, self.type_name()) 

177 try: 

178 self._obj.delete() 

179 except GitlabCreateError as e: 

180 logger.error("[%s] Unable to delete %s (%s)", self._name, self.type_name(), e.error_message) 

181 

182 """"mangle_param() 

183 

184 Convert a param value from GitLabracadabra form to API form. 

185 """ 

186 

187 def mangle_param( 

188 self, 

189 param_name, # noqa: ARG002 

190 param_value, 

191 ): 

192 return param_value 

193 

194 """"unmangle_param() 

195 

196 Convert a param value from API form to GitLabracadabra form. 

197 """ 

198 

199 def unmangle_param(self, param_name, param_value): 

200 if param_value is None: 

201 # Map None values to empty string 

202 # (can be overridden with x-gitlabracadabra-none-value in schema) 

203 return self.SCHEMA.get("properties", {}).get(param_name).get("x-gitlabracadabra-none-value", "") 

204 if isinstance(param_value, str): 

205 # GitLab normalize to CRLF 

206 # YAML normalize to LF 

207 return param_value.replace("\r\n", "\n") 

208 return param_value 

209 

210 """"_canonalize_param() 

211 

212 Canonalize a param value. 

213 """ 

214 

215 def _canonalize_param( 

216 self, 

217 param_name, # noqa: ARG002 

218 param_value, 

219 ): 

220 if isinstance(param_value, list): 

221 return sorted(param_value) 

222 return param_value 

223 

224 """"_get_param() 

225 

226 Get a param value. 

227 """ 

228 

229 def _get_param(self, param_name): 

230 return getattr(self._obj, param_name) 

231 

232 """"_process_param() 

233 

234 Process one param. 

235 """ 

236 

237 def _process_param(self, param_name, param_value, *, dry_run=False, skip_save=False): 

238 if param_name in self.IGNORED_PARAMS and not skip_save: 

239 return 

240 target_value = self._canonalize_param(param_name, param_value) 

241 try: 

242 current_value = self._canonalize_param( 

243 param_name, self.unmangle_param(param_name, self._get_param(param_name)) 

244 ) 

245 except AttributeError: 

246 if not skip_save: 

247 # FIXME: Hidden attributes cannot be idempotent (i.e password) 

248 logger.info( 

249 "[%s] NOT Changing param %s: %s -> %s (current value is not available)", 

250 self._name, 

251 param_name, 

252 None, 

253 target_value, 

254 ) 

255 return 

256 current_value = None 

257 if current_value != target_value or skip_save: 

258 if "dependencies" in self.SCHEMA and param_name in self.SCHEMA["dependencies"]: 

259 for dependency in self.SCHEMA["dependencies"][param_name]: 

260 process_method = getattr(self, "_process_" + dependency, self._process_param) 

261 process_method(dependency, self._content[dependency], dry_run=dry_run, skip_save=True) 

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

263 logger.info( 

264 "[%s] NOT Changing param %s: %s -> %s (dry-run)", 

265 self._name, 

266 param_name, 

267 current_value, 

268 target_value, 

269 ) 

270 setattr(self._obj, param_name, self.mangle_param(param_name, target_value)) 

271 else: 

272 logger.info("[%s] Changing param %s: %s -> %s", self._name, param_name, current_value, target_value) 

273 setattr(self._obj, param_name, self.mangle_param(param_name, target_value)) 

274 if not skip_save: 

275 try: 

276 self._obj.save() 

277 except GitlabUpdateError as e: 

278 logger.error( 

279 "[%s] Unable to change param %s (%s -> %s): %s", 

280 self._name, 

281 param_name, 

282 current_value, 

283 target_value, 

284 e.error_message, 

285 ) 

286 

287 """"_get() 

288 

289 Get the _object attribute 

290 """ 

291 

292 def _get(self) -> Any: 

293 obj_manager = self._object_manager() # type:ignore 

294 if self.FIND_PARAM: 

295 params = {self.FIND_PARAM: self._name} 

296 try: 

297 self._obj = obj_manager.list(**params)[0] 

298 except IndexError: 

299 self._obj = None 

300 else: 

301 try: 

302 self._obj = obj_manager.get(self._name) 

303 except GitlabGetError as err: 

304 if err.response_code != HTTPStatus.NOT_FOUND: 304 ↛ 305line 304 didn't jump to line 305 because the condition on line 304 was never true

305 pass 

306 self._obj = None 

307 

308 """"process() 

309 

310 Process the object. 

311 """ 

312 

313 def process(self, *, dry_run: bool = False) -> None: 

314 content_copy = deepcopy(self._content) 

315 self._get() 

316 if self._delete_object: 

317 self._delete(dry_run=dry_run) 

318 return 

319 if self._obj is None: 

320 if self._create_object: 

321 self._obj = self._create(dry_run=dry_run) 

322 if self._obj is None: 

323 return 

324 self._just_created = True 

325 else: 

326 logger.info("[%s] NOT Creating %s (create_object is false)", self._name, self.type_name()) 

327 return 

328 for param_name, param_value in sorted(self._content.items(), key=self._content_items_sort_key): 

329 process_method = getattr(self, "_process_" + param_name, self._process_param) 

330 try: 

331 process_method(param_name, param_value, dry_run=dry_run) 

332 except (GitlabCreateError, GitlabDeleteError, GitlabGetError, GitlabListError, GitlabUpdateError) as e: 

333 logger.error("[%s] Error while processing param %s: %s", self._name, param_name, e.error_message) 

334 if content_copy != self._content: 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true

335 msg = f"[{self._name}] Changed values during processing" 

336 raise RuntimeError(msg) 

337 

338 """"_content_items_sort_key() 

339 

340 Get content.items() sort key from DOC. 

341 """ 

342 

343 def _content_items_sort_key(self, content_item: tuple[str, Any]) -> tuple[int, str]: 

344 return ( 

345 self.SCHEMA.get("properties", {}).get(content_item[0]).get("x-gitlabracadabra-order", 0), 

346 content_item[0], 

347 ) 

348 

349 """"_markdown_link() 

350 

351 Generate a Markdown link. 

352 """ 

353 

354 @classmethod 

355 def _markdown_link(cls, header: str) -> str: 

356 # Remove heading # 

357 out = re.sub(r"^#+", "", header) 

358 # Trim and lower 

359 out = out.strip().lower() 

360 # Remove non-word characters 

361 out = re.sub(r"[^\w\- ]+", " ", out) 

362 # Replace multiple spaces with dash 

363 out = re.sub(r"\s+", "-", out) 

364 # Remove trailing dashed 

365 out = re.sub(r"-+$", "", out) 

366 return "#" + out 

367 

368 """"doc_markdown() 

369 

370 Generate Markdown documentation. 

371 """ 

372 

373 @classmethod 

374 def doc_markdown(cls) -> str: 

375 output = "" 

376 properties = cls.SCHEMA.get("properties", {}) 

377 first_undocumented = True 

378 for p in sorted(properties): 

379 if p not in cls.DOC: 

380 if first_undocumented: 

381 cls.DOC.append("# Undocumented") 

382 first_undocumented = False 

383 cls.DOC.append(p) 

384 # Generate TOC 

385 output += "# Table of Contents <!-- omit in toc -->" + "\n\n" 

386 for doc in cls.DOC: 

387 matches = re.match(r"^(#+) ?(.*)$", doc) 

388 if matches: 

389 indent = " " * (len(matches.group(1)) - 1) 

390 output += indent + "- [" + doc.replace("#", "").strip() + "](" + cls._markdown_link(doc) + ")\n" 

391 output += "\n" 

392 # Detail 

393 for doc in cls.DOC: 

394 if doc[0] == "#": 

395 output += doc + "\n\n" 

396 elif doc in properties: 

397 example = cls.EXAMPLE_YAML_HEADER 

398 if "_example" in properties[doc]: 

399 if properties[doc]["_example"].startswith("\n"): 

400 example += " " + doc + ":" + properties[doc]["_example"] + "\n" 

401 else: 

402 example += " " + doc + ": " + properties[doc]["_example"] + "\n" 

403 elif "enum" in properties[doc]: 

404 enum = properties[doc]["enum"] 

405 example += ( 

406 " " + doc + ": " + str(enum[0]) + " # one of " + ", ".join([str(item) for item in enum]) + "\n" 

407 ) 

408 elif properties[doc]["type"] == "boolean": 

409 example += " " + doc + ": true # or false\n" 

410 elif properties[doc]["type"] == "integer": 

411 example += " " + doc + ": 42\n" 

412 elif properties[doc]["type"] == "array": 

413 example += " " + doc + ": [My " + doc.replace("_", " ") + "]\n" 

414 else: 

415 example += " " + doc + ": My " + doc.replace("_", " ") + "\n" 

416 output += ( 

417 "`" + doc + "` " + properties[doc].get("description", "Undocumented").removesuffix(".") + ":\n" 

418 ) 

419 output += "```yaml\n" + example + "```\n\n" 

420 if "_doc_link" in properties[doc]: 

421 output += "More information can be found [here]({})\n\n".format(properties[doc]["_doc_link"]) 

422 else: 

423 output += "`" + doc + "` *Undocumented*.\n\n" 

424 return output