Coverage for src/cli/main.py: 96.38%

138 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 21:20 +0000

1#!/usr/bin/env python3 

2 

3# Standard libraries 

4from argparse import ( 

5 _ArgumentGroup, 

6 _MutuallyExclusiveGroup, 

7 ArgumentParser, 

8 Namespace, 

9 RawTextHelpFormatter, 

10) 

11from os import environ 

12from shutil import get_terminal_size 

13from sys import exit as sys_exit 

14 

15# Components 

16from ..package.bundle import Bundle 

17from ..package.settings import Settings 

18from ..package.updates import Updates 

19from ..package.version import Version 

20from ..prints.colors import Colors 

21from ..system.platform import Platform 

22from ..types.environments import Environments 

23from ..types.gitlab import ( 

24 MergeRequestsMethod, 

25 MergeRequestsPipelines, 

26 MergeRequestsResolved, 

27 MergeRequestsSkipped, 

28 MergeRequestsSquash, 

29 ProtectionLevels, 

30 RolesCreateProjects, 

31 RolesCreateSubgroups, 

32) 

33from .entrypoint import Entrypoint 

34 

35# Constants 

36HELP_POSITION: int = 39 

37 

38# Exception 

39def exception(error: BaseException) -> None: 

40 raise error 

41 

42# Main, pylint: disable=too-many-branches,too-many-statements 

43def main() -> None: 

44 

45 # Variables 

46 environments: Environments 

47 group: _ArgumentGroup 

48 result: Entrypoint.Result = Entrypoint.Result.ERROR 

49 subgroup: _MutuallyExclusiveGroup 

50 

51 # Configure environment variables 

52 environments = Environments() 

53 environments.group = 'environment variables' 

54 environments.add( 

55 'gitlab_token', 

56 Bundle.ENV_GITLAB_TOKEN, 

57 'GitLab API token environment variable', 

58 ) 

59 environments.add( 

60 'ci_job_token', 

61 Bundle.ENV_CI_JOB_TOKEN, 

62 'GitLab CI job token environment variable (CI only)', 

63 ) 

64 

65 # Arguments creation 

66 parser: ArgumentParser = ArgumentParser( 

67 prog=Bundle.NAME, 

68 description=f'{Bundle.NAME}: {Bundle.DESCRIPTION}', 

69 epilog=environments.help(HELP_POSITION), 

70 add_help=False, 

71 formatter_class=lambda prog: RawTextHelpFormatter( 

72 prog, 

73 max_help_position=HELP_POSITION, 

74 width=min( 

75 120, 

76 get_terminal_size().columns - 2, 

77 ), 

78 ), 

79 ) 

80 

81 # Arguments internal definitions 

82 group = parser.add_argument_group('internal arguments') 

83 group.add_argument( 

84 '-h', 

85 '--help', 

86 dest='help', 

87 action='store_true', 

88 help='Show this help message', 

89 ) 

90 group.add_argument( 

91 '--version', 

92 dest='version', 

93 action='store_true', 

94 help='Show the current version', 

95 ) 

96 group.add_argument( 

97 '--no-color', 

98 dest='no_color', 

99 action='store_true', 

100 help=f'Disable colors outputs with \'{Bundle.ENV_NO_COLOR}=1\'\n' 

101 '(or default settings: [themes] > no_color)', 

102 ) 

103 group.add_argument( 

104 '--update-check', 

105 dest='update_check', 

106 action='store_true', 

107 help='Check for newer package updates', 

108 ) 

109 group.add_argument( 

110 '--settings', 

111 dest='settings', 

112 action='store_true', 

113 help='Show the current settings path and contents', 

114 ) 

115 group.add_argument( 

116 '--set', 

117 dest='set', 

118 action='store', 

119 metavar=('GROUP', 'KEY', 'VAL'), 

120 nargs=3, 

121 help='Set settings specific \'VAL\' value to [GROUP] > KEY\n' \ 

122 'or unset by using \'UNSET\' as \'VAL\'', 

123 ) 

124 

125 # Arguments credentials definitions 

126 group = parser.add_argument_group('credentials arguments') 

127 group.add_argument( 

128 '-c', 

129 '--config', 

130 dest='configs', 

131 action='append', 

132 metavar='FILES', 

133 help=f'Python GitLab configuration files' 

134 f' (default: {Bundle.ENV_PYTHON_GITLAB_CFG} environment)', 

135 ) 

136 

137 # Arguments common settings definitions 

138 group = parser.add_argument_group('common settings arguments') 

139 group.add_argument( 

140 '--confirm', 

141 dest='confirm', 

142 action='store_true', 

143 help='Automatically confirm all removal and contents warnings', 

144 ) 

145 group.add_argument( 

146 '--dry-run', 

147 dest='dry_run', 

148 action='store_true', 

149 help='Enable dry run mode to check without saving', 

150 ) 

151 group.add_argument( 

152 '--dump', 

153 dest='dump', 

154 action='store_true', 

155 help='Dump Python objects of groups and projects', 

156 ) 

157 group.add_argument( 

158 '--exclude-group', 

159 dest='exclude_group', 

160 action='store_true', 

161 help='Exclude parent group settings', 

162 ) 

163 group.add_argument( 

164 '--exclude-subgroups', 

165 dest='exclude_subgroups', 

166 action='store_true', 

167 help='Exclude children subgroups settings', 

168 ) 

169 group.add_argument( 

170 '--exclude-projects', 

171 dest='exclude_projects', 

172 action='store_true', 

173 help='Exclude children projects settings', 

174 ) 

175 

176 # Arguments general settings definitions 

177 group = parser.add_argument_group('general settings arguments') 

178 group.add_argument( 

179 '--available-features', 

180 dest='available_features', 

181 action='store_true', 

182 help='List the available GitLab project features known by the tool', 

183 ) 

184 group.add_argument( 

185 '--reset-features', 

186 dest='reset_features', 

187 action='store', 

188 metavar='KEEP_FEATURES', 

189 nargs='?', 

190 const='', 

191 help='Reset features of GitLab projects based on usage\n' 

192 '(Optionally keep features separated by ",")', 

193 ) 

194 group.add_argument( 

195 '--disable-features', 

196 dest='disable_features', 

197 metavar='FEATURES', 

198 help='List of features to disable separated by ","', 

199 ) 

200 group.add_argument( 

201 '--enable-features', 

202 dest='enable_features', 

203 metavar='FEATURES', 

204 help='List of features to enable separated by ","', 

205 ) 

206 group.add_argument( 

207 '--reset-members', 

208 dest='reset_members', 

209 action='store_true', 

210 help='Reset members of GitLab projects and groups', 

211 ) 

212 group.add_argument( 

213 '--set-avatar', 

214 dest='set_avatar', 

215 action='store', 

216 metavar='FILE', 

217 help='Set avatar of GitLab projects and groups', 

218 ) 

219 group.add_argument( 

220 '--set-description', 

221 dest='set_description', 

222 action='store', 

223 metavar='TEXT', 

224 help='Set description of GitLab projects and groups', 

225 ) 

226 group.add_argument( 

227 '--update-descriptions', 

228 dest='update_description', 

229 action='store_true', 

230 help='Update description of GitLab projects and groups automatically', 

231 ) 

232 

233 # Arguments group settings definitions 

234 group = parser.add_argument_group('group settings arguments') 

235 group.add_argument( 

236 '--set-roles-create-projects', 

237 dest='set_roles_create_projects', 

238 action='store', 

239 metavar='ROLE', 

240 nargs='?', 

241 const=RolesCreateProjects.default(), 

242 help= 

243 f'Set roles allowed to create projects [{",".join(RolesCreateProjects.names())}]' 

244 ' (default: %(const)s)', 

245 ) 

246 group.add_argument( 

247 '--set-roles-create-subgroups', 

248 dest='set_roles_create_subgroups', 

249 action='store', 

250 metavar='ROLE', 

251 nargs='?', 

252 const=RolesCreateSubgroups.default(), 

253 help= 

254 f'Set roles allowed to create subgroups [{",".join(RolesCreateSubgroups.names())}]' 

255 ' (default: %(const)s)', 

256 ) 

257 

258 # Arguments advanced settings definitions 

259 group = parser.add_argument_group('advanced settings arguments') 

260 subgroup = group.add_mutually_exclusive_group() 

261 subgroup.add_argument( 

262 '--run-housekeeping', 

263 dest='run_housekeeping', 

264 action='store_true', 

265 help='Run housekeeping of GitLab project or projects in groups', 

266 ) 

267 subgroup.add_argument( 

268 '--prune-unreachable-objects', 

269 dest='prune_unreachable_objects', 

270 action='store_true', 

271 help='Prune unreachable objects of GitLab project or projects in groups', 

272 ) 

273 subgroup = group.add_mutually_exclusive_group() 

274 subgroup.add_argument( 

275 '--archive-projects', 

276 dest='archive_project', 

277 action='store_true', 

278 help='Archive project or projects in GitLab groups', 

279 ) 

280 subgroup.add_argument( 

281 '--unarchive-projects', 

282 dest='unarchive_project', 

283 action='store_true', 

284 help='Unarchive project or projects in GitLab groups', 

285 ) 

286 group.add_argument( 

287 '--delete-groups', 

288 dest='delete_group', 

289 action='store_true', 

290 help='Delete group or groups in GitLab groups', 

291 ) 

292 group.add_argument( 

293 '--delete-projects', 

294 dest='delete_project', 

295 action='store_true', 

296 help='Delete project or projects in GitLab groups', 

297 ) 

298 

299 # Arguments repository settings definitions 

300 group = parser.add_argument_group('repository settings arguments') 

301 group.add_argument( 

302 '--protect-branches', 

303 dest='protect_branches', 

304 action='store_true', 

305 help='Protect branches with default master/main, develop and staging', 

306 ) 

307 group.add_argument( 

308 '--protect-tags', 

309 dest='protect_tags', 

310 action='store', 

311 metavar='LEVEL', 

312 nargs='?', 

313 const=ProtectionLevels.default(), 

314 help=f'Protect tags at level [{",".join(ProtectionLevels.names())}]' 

315 ' (default: %(const)s)', 

316 ) 

317 

318 # Arguments merge requests settings definitions 

319 group = parser.add_argument_group('merge requests settings arguments') 

320 group.add_argument( 

321 '--set-merge-method', 

322 dest='set_merge_method', 

323 metavar='METHOD', 

324 nargs='?', 

325 type=lambda value: ( 

326 value if value in MergeRequestsMethod.VALUES # Keys 

327 else 

328 [key for key, _val in MergeRequestsMethod.VALUES.items() if _val == value][0] 

329 if value in MergeRequestsMethod.VALUES.values() # Values 

330 else exception(ValueError(value)) # Unknown 

331 ), 

332 const=MergeRequestsMethod.DEFAULT, 

333 help='Set project merge requests method' 

334 f' ({", ".join(MergeRequestsMethod.VALUES.keys())}, default: %(const)s)', 

335 ) 

336 group.add_argument( 

337 '--set-merge-squash', 

338 dest='set_merge_squash', 

339 metavar='SQUASH', 

340 nargs='?', 

341 type=lambda value: ( 

342 value if value in MergeRequestsSquash.VALUES # Keys 

343 else 

344 [key for key, _val in MergeRequestsSquash.VALUES.items() if _val == value][0] 

345 if value in MergeRequestsSquash.VALUES.values() # Values 

346 else exception(ValueError(value)) # Unknown 

347 ), 

348 const=MergeRequestsSquash.DEFAULT, 

349 help='Set project merge requests squashing' 

350 f' ({", ".join(MergeRequestsSquash.VALUES.keys())}, default: %(const)s)', 

351 ) 

352 group.add_argument( 

353 '--set-merge-pipelines', 

354 dest='set_merge_pipelines', 

355 metavar='CHECK', 

356 nargs='?', 

357 type=lambda x: (str(x).lower() in ('true', 't', 'yes', 'y', 'on', '1')), 

358 const=MergeRequestsPipelines.DEFAULT, 

359 help='Set project merge requests check for successful pipelines' 

360 ' (true, false, default: %(const)s)', 

361 ) 

362 group.add_argument( 

363 '--set-merge-skipped', 

364 dest='set_merge_skipped', 

365 metavar='CHECK', 

366 nargs='?', 

367 type=lambda x: (str(x).lower() in ('true', 't', 'yes', 'y', 'on', '1')), 

368 const=MergeRequestsSkipped.DEFAULT, 

369 help='Set project merge requests check for skipped pipelines' 

370 ' (true, false, default: %(const)s)', 

371 ) 

372 group.add_argument( 

373 '--set-merge-resolved', 

374 dest='set_merge_resolved', 

375 metavar='CHECK', 

376 nargs='?', 

377 type=lambda x: (str(x).lower() in ('true', 't', 'yes', 'y', 'on', '1')), 

378 const=MergeRequestsResolved.DEFAULT, 

379 help='Set project merge requests check for resolved threads' 

380 ' (true, false, default: %(const)s)', 

381 ) 

382 

383 # Arguments CI/CD settings definitions 

384 group = parser.add_argument_group('ci/cd settings arguments') 

385 group.add_argument( 

386 '--add-jobs-token-allowlist', 

387 dest='add_jobs_token_allowlist', 

388 action='store', 

389 metavar='PATH', 

390 help='Add a group or project to CI/CD job token allowlist', 

391 ) 

392 group.add_argument( 

393 '--remove-jobs-token-allowlist', 

394 dest='remove_jobs_token_allowlist', 

395 action='store', 

396 metavar='PATH', 

397 help='Remove a group or project from CI/CD job token allowlist', 

398 ) 

399 subgroup = group.add_mutually_exclusive_group() 

400 subgroup.add_argument( 

401 '--erase-jobs-artifacts', 

402 dest='erase_jobs_artifacts', 

403 action='store_true', 

404 help='Erase all CI/CD jobs artifacts', 

405 ) 

406 subgroup.add_argument( 

407 '--erase-jobs-contents', 

408 dest='erase_jobs_contents', 

409 action='store_true', 

410 help='Erase all CI/CD jobs artifacts and traces', 

411 ) 

412 

413 # Arguments issues definitions 

414 group = parser.add_argument_group('issues arguments') 

415 group.add_argument( 

416 '--get-project-issues-boards', 

417 dest='get_project_issues_boards', 

418 action='store_true', 

419 help='Get the GitLab project issues boards in JSON format', 

420 ) 

421 group.add_argument( 

422 '--set-project-issues-boards', 

423 dest='set_project_issues_boards', 

424 metavar='JSON', 

425 help='Set the GitLab project issues boards from JSON format', 

426 ) 

427 

428 # Arguments labels definitions 

429 group = parser.add_argument_group('labels arguments') 

430 subgroup = group.add_mutually_exclusive_group() 

431 subgroup.add_argument( 

432 '--get-group-labels', 

433 dest='get_group_labels', 

434 action='store_true', 

435 help='Get the GitLab group labels in JSON format', 

436 ) 

437 subgroup.add_argument( 

438 '--set-group-labels', 

439 dest='set_group_labels', 

440 metavar='JSON', 

441 help='Set the GitLab group labels from JSON format', 

442 ) 

443 subgroup = group.add_mutually_exclusive_group() 

444 subgroup.add_argument( 

445 '--get-project-labels', 

446 dest='get_project_labels', 

447 action='store_true', 

448 help='Get the GitLab project labels in JSON format', 

449 ) 

450 subgroup.add_argument( 

451 '--set-project-labels', 

452 dest='set_project_labels', 

453 metavar='JSON', 

454 help='Set the GitLab project labels from JSON format', 

455 ) 

456 

457 # Arguments positional definitions 

458 group = parser.add_argument_group('positional arguments') 

459 group.add_argument( 

460 '--', 

461 dest='double_dash', 

462 action='store_true', 

463 help='Positional arguments separator (recommended)', 

464 ) 

465 group.add_argument( 

466 dest='url_path', 

467 action='store', 

468 nargs='?', 

469 help='GitLab group or project path URL', 

470 ) 

471 

472 # Arguments parser 

473 options: Namespace = parser.parse_args() 

474 

475 # Help informations 

476 if options.help: 

477 print(' ') 

478 parser.print_help() 

479 print(' ') 

480 Platform.flush() 

481 sys_exit(0) 

482 

483 # Instantiate settings 

484 settings: Settings = Settings(name=Bundle.NAME) 

485 

486 # Prepare no_color 

487 if not options.no_color: 

488 if settings.has('themes', 'no_color'): 

489 options.no_color = settings.get_bool('themes', 'no_color') 

490 else: 

491 options.no_color = False 

492 settings.set_bool('themes', 'no_color', options.no_color) 

493 

494 # Configure no_color 

495 if options.no_color: 

496 environ[Bundle.ENV_FORCE_COLOR] = '0' 

497 environ[Bundle.ENV_NO_COLOR] = '1' 

498 

499 # Prepare colors 

500 Colors.prepare() 

501 

502 # Settings setter 

503 if options.set: 

504 settings.set(options.set[0], options.set[1], options.set[2]) 

505 settings.show() 

506 sys_exit(0) 

507 

508 # Settings informations 

509 if options.settings: 

510 settings.show() 

511 sys_exit(0) 

512 

513 # Instantiate updates 

514 updates: Updates = Updates( 

515 name=Bundle.PACKAGE, 

516 settings=settings, 

517 ) 

518 

519 # Version informations 

520 if options.version: 

521 print( 

522 f'{Bundle.NAME} {Version.get()} from {Version.path()} (python {Version.python()})' 

523 ) 

524 Platform.flush() 

525 sys_exit(0) 

526 

527 # Check for current updates 

528 if options.update_check: 

529 if not updates.check(): 

530 updates.check(older=True) 

531 sys_exit(0) 

532 

533 # Arguments validation 

534 if not options.url_path: 

535 result = Entrypoint.Result.CRITICAL 

536 

537 # Header 

538 print(' ') 

539 Platform.flush() 

540 

541 # Tool identifier 

542 if result != Entrypoint.Result.CRITICAL: 

543 print(f'{Colors.BOLD} {Bundle.NAME}' 

544 f'{Colors.YELLOW_LIGHT} ({Version.get()})' 

545 f'{Colors.RESET}') 

546 Platform.flush() 

547 

548 # CLI entrypoint 

549 if result != Entrypoint.Result.CRITICAL: 

550 result = Entrypoint.cli( 

551 options, 

552 environments, 

553 ) 

554 

555 # CLI helper 

556 else: 

557 parser.print_help() 

558 

559 # Footer 

560 print(' ') 

561 Platform.flush() 

562 

563 # Check for daily updates 

564 if updates.enabled and updates.daily: 

565 updates.check() 

566 

567 # Result 

568 if result in [ 

569 Entrypoint.Result.SUCCESS, 

570 Entrypoint.Result.FINALIZE, 

571 ]: 

572 sys_exit(0) 

573 else: 

574 sys_exit(1) 

575 

576# Entrypoint 

577if __name__ == '__main__': # pragma: no cover 

578 main()