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 import logging

20 import re

21 from copy import deepcopy

22 from http import HTTPStatus

23 from typing import TYPE_CHECKING, ClassVar

24

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

26 from jsonschema.validators import validator_for

27

28 from gitlabracadabra.gitlab.connections import GitlabConnections

29

30 if TYPE_CHECKING: 30 ↛ 31 line 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

38 logger = logging.getLogger(__name__)

39

40

41 class 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 ↛ 85 line 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 ↛ 139 line 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 ↛ 149 line 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 ↛ 156 line 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 ↛ 172 line 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 ↛ 174 line 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 ↛ 263 line 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 ↛ 305 line 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 ↛ 335 line 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