gitlabform 0.0.540a0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. gitlabform/__init__.py +719 -0
  2. gitlabform/configuration/__init__.py +12 -0
  3. gitlabform/configuration/common.py +19 -0
  4. gitlabform/configuration/core.py +323 -0
  5. gitlabform/configuration/groups.py +127 -0
  6. gitlabform/configuration/projects.py +73 -0
  7. gitlabform/configuration/transform.py +259 -0
  8. gitlabform/constants.py +7 -0
  9. gitlabform/gitlab/__init__.py +108 -0
  10. gitlabform/gitlab/commits.py +39 -0
  11. gitlabform/gitlab/core.py +334 -0
  12. gitlabform/gitlab/group_badges.py +50 -0
  13. gitlabform/gitlab/group_ldap_links.py +40 -0
  14. gitlabform/gitlab/groups.py +96 -0
  15. gitlabform/gitlab/merge_requests.py +57 -0
  16. gitlabform/gitlab/pipelines.py +23 -0
  17. gitlabform/gitlab/project_badges.py +52 -0
  18. gitlabform/gitlab/project_deploy_keys.py +102 -0
  19. gitlabform/gitlab/project_merge_requests_approvals.py +94 -0
  20. gitlabform/gitlab/project_protected_environments.py +37 -0
  21. gitlabform/gitlab/projects.py +151 -0
  22. gitlabform/gitlab/python_gitlab.py +251 -0
  23. gitlabform/gitlab/variables.py +47 -0
  24. gitlabform/lists/__init__.py +62 -0
  25. gitlabform/lists/filter.py +99 -0
  26. gitlabform/lists/groups.py +87 -0
  27. gitlabform/lists/projects.py +239 -0
  28. gitlabform/output.py +46 -0
  29. gitlabform/processors/__init__.py +43 -0
  30. gitlabform/processors/abstract_processor.py +187 -0
  31. gitlabform/processors/application/__init__.py +17 -0
  32. gitlabform/processors/application/application_settings_processor.py +39 -0
  33. gitlabform/processors/defining_keys.py +152 -0
  34. gitlabform/processors/group/__init__.py +48 -0
  35. gitlabform/processors/group/group_badges_processor.py +17 -0
  36. gitlabform/processors/group/group_hooks_processor.py +75 -0
  37. gitlabform/processors/group/group_labels_processor.py +28 -0
  38. gitlabform/processors/group/group_ldap_links_processor.py +16 -0
  39. gitlabform/processors/group/group_members_processor.py +287 -0
  40. gitlabform/processors/group/group_push_rules_processor.py +44 -0
  41. gitlabform/processors/group/group_saml_links_processor.py +65 -0
  42. gitlabform/processors/group/group_settings_processor.py +90 -0
  43. gitlabform/processors/group/group_variables_processor.py +26 -0
  44. gitlabform/processors/multiple_entities_processor.py +171 -0
  45. gitlabform/processors/project/__init__.py +80 -0
  46. gitlabform/processors/project/badges_processor.py +17 -0
  47. gitlabform/processors/project/branches_processor.py +514 -0
  48. gitlabform/processors/project/deploy_keys_processor.py +18 -0
  49. gitlabform/processors/project/files_processor.py +301 -0
  50. gitlabform/processors/project/hooks_processor.py +64 -0
  51. gitlabform/processors/project/integrations_processor.py +33 -0
  52. gitlabform/processors/project/job_token_scope_processor.py +216 -0
  53. gitlabform/processors/project/members_processor.py +204 -0
  54. gitlabform/processors/project/merge_requests_approval_rules.py +17 -0
  55. gitlabform/processors/project/merge_requests_approvals.py +59 -0
  56. gitlabform/processors/project/project_labels_processor.py +27 -0
  57. gitlabform/processors/project/project_processor.py +62 -0
  58. gitlabform/processors/project/project_push_rules_processor.py +52 -0
  59. gitlabform/processors/project/project_security_settings.py +66 -0
  60. gitlabform/processors/project/project_settings_processor.py +239 -0
  61. gitlabform/processors/project/project_variables_processor.py +94 -0
  62. gitlabform/processors/project/remote_mirrors_processor.py +278 -0
  63. gitlabform/processors/project/resource_groups_processor.py +48 -0
  64. gitlabform/processors/project/schedules_processor.py +208 -0
  65. gitlabform/processors/project/tags_processor.py +108 -0
  66. gitlabform/processors/shared/__init__.py +0 -0
  67. gitlabform/processors/shared/protected_environments_processor.py +20 -0
  68. gitlabform/processors/util/__init__.py +0 -0
  69. gitlabform/processors/util/decorators.py +44 -0
  70. gitlabform/processors/util/difference_logger.py +70 -0
  71. gitlabform/processors/util/labels_processor.py +120 -0
  72. gitlabform/processors/util/variables_processor.py +143 -0
  73. gitlabform/run.py +9 -0
  74. gitlabform/util.py +7 -0
  75. gitlabform-0.0.540a0.dist-info/METADATA +54 -0
  76. gitlabform-0.0.540a0.dist-info/RECORD +79 -0
  77. gitlabform-0.0.540a0.dist-info/WHEEL +4 -0
  78. gitlabform-0.0.540a0.dist-info/entry_points.txt +9 -0
  79. gitlabform-0.0.540a0.dist-info/licenses/LICENSE +21 -0
gitlabform/__init__.py ADDED
@@ -0,0 +1,719 @@
1
+ import sys
2
+ from logging import debug, critical, error, warning, info
3
+
4
+ # Use Rich to make logs have a colorized and formatted output
5
+ from rich.logging import RichHandler
6
+ from rich.console import Console
7
+
8
+ import argparse
9
+ import logging
10
+ import luddite
11
+ from importlib.metadata import version as package_version
12
+ import textwrap
13
+ import traceback
14
+
15
+ from packaging import version
16
+ from typing import Any, Tuple
17
+
18
+ from gitlabform.configuration import Configuration
19
+ from gitlabform.configuration.core import (
20
+ ConfigFileNotFoundException,
21
+ ConfigInvalidException,
22
+ )
23
+ from gitlabform.configuration.transform import ConfigurationTransformers
24
+ from gitlabform.constants import EXIT_INVALID_INPUT, EXIT_PROCESSING_ERROR
25
+ from gitlabform.gitlab import GitLab
26
+ from gitlabform.gitlab.core import TestRequestFailedException
27
+ from gitlabform.lists import Entities
28
+ from gitlabform.lists.filter import GroupsAndProjectsFilters
29
+ from gitlabform.lists.groups import GroupsProvider
30
+ from gitlabform.lists.projects import ProjectsProvider
31
+ from gitlabform.output import EffectiveConfigurationFile
32
+ from gitlabform.processors.application import ApplicationProcessors
33
+ from gitlabform.processors.group import GroupProcessors
34
+ from gitlabform.processors.project import ProjectProcessors
35
+
36
+ console = Console()
37
+
38
+
39
+ class GitLabForm:
40
+ def __init__(
41
+ self,
42
+ include_archived_projects=True,
43
+ include_projects_scheduled_for_deletion=True,
44
+ target=None,
45
+ config_string=None,
46
+ noop=False,
47
+ output_file=None,
48
+ recurse_subgroups=True,
49
+ ):
50
+ if target and config_string:
51
+ # this mode is basically only for testing
52
+
53
+ self.target = target
54
+ self.config_string = config_string
55
+ self.verbose = True
56
+ self.debug = True
57
+ self.strict = True
58
+ self.start_from = 1
59
+ self.start_from_group = 1
60
+ self.noop = noop
61
+ self.diff_only_changed = False
62
+ self.output_file = output_file
63
+ self.skip_version_check = True
64
+ self.include_archived_projects = include_archived_projects
65
+ self.include_projects_scheduled_for_deletion = include_projects_scheduled_for_deletion
66
+ self.just_show_version = False
67
+ self.terminate_after_error = True
68
+ self.only_sections = "all"
69
+ self.exclude_sections = []
70
+ self.recurse_subgroups = recurse_subgroups
71
+
72
+ self._configure_output(tests=True)
73
+ else:
74
+ # normal mode
75
+
76
+ (
77
+ self.target,
78
+ self.config,
79
+ self.verbose,
80
+ self.debug,
81
+ self.strict,
82
+ self.start_from,
83
+ self.start_from_group,
84
+ self.noop,
85
+ self.diff_only_changed,
86
+ self.output_file,
87
+ self.skip_version_check,
88
+ self.include_archived_projects,
89
+ self.include_projects_scheduled_for_deletion,
90
+ self.just_show_version,
91
+ self.terminate_after_error,
92
+ self.only_sections,
93
+ self.exclude_sections,
94
+ self.recurse_subgroups,
95
+ ) = self._parse_args()
96
+
97
+ self._configure_output()
98
+
99
+ self._show_version(self.skip_version_check)
100
+ if self.just_show_version:
101
+ sys.exit(0)
102
+
103
+ if not self.target:
104
+ critical("target parameter is required.")
105
+ sys.exit(EXIT_INVALID_INPUT)
106
+
107
+ self.gitlab, self.configuration = self._initialize_configuration_and_gitlab()
108
+
109
+ self.application_processors = ApplicationProcessors(self.gitlab, self.configuration, self.strict)
110
+ self.group_processors = GroupProcessors(self.gitlab, self.configuration, self.strict)
111
+ self.project_processors = ProjectProcessors(self.gitlab, self.configuration, self.strict)
112
+ self.groups_provider = GroupsProvider(
113
+ self.gitlab,
114
+ self.configuration,
115
+ self.recurse_subgroups,
116
+ )
117
+ self.projects_provider = ProjectsProvider(
118
+ self.gitlab,
119
+ self.configuration,
120
+ self.include_archived_projects,
121
+ self.include_projects_scheduled_for_deletion,
122
+ self.recurse_subgroups,
123
+ )
124
+
125
+ self.groups_and_projects_filters = GroupsAndProjectsFilters(
126
+ self.configuration, self.group_processors, self.project_processors
127
+ )
128
+
129
+ @staticmethod
130
+ def _parse_args() -> Tuple:
131
+ """
132
+ Parses the input command-line arguments.
133
+
134
+ :return: a tuple with all the arguments that have been parsed
135
+ """
136
+ parser = argparse.ArgumentParser(
137
+ description=textwrap.dedent(f"""
138
+ 🏗 Specialized configuration as a code tool for GitLab projects, groups and more
139
+ using hierarchical configuration written in YAML.
140
+
141
+ Exits with code:
142
+ * 0 - on success,
143
+ * {EXIT_INVALID_INPUT} - on invalid input errors (f.e. bad syntax in the config file) ~ "it's your fault". 😅
144
+ * {EXIT_PROCESSING_ERROR} - if there were backend processing errors (f.e. when requests to GitLab fail) ~ "it's not your fault". 😎
145
+ """),
146
+ formatter_class=Formatter,
147
+ )
148
+
149
+ parser.add_argument(
150
+ "target",
151
+ nargs="?",
152
+ help='Project name in "group/project" format \n'
153
+ "OR a single group name \n"
154
+ 'OR "ALL_DEFINED" to run for all groups and projects defined the config \n'
155
+ 'OR "ALL" to run for all projects that you have access to',
156
+ )
157
+
158
+ parser.add_argument(
159
+ "-V",
160
+ "--version",
161
+ dest="just_show_version",
162
+ action="store_true",
163
+ help="show version and exit",
164
+ )
165
+
166
+ parser.add_argument(
167
+ "-k",
168
+ "--skip-version-check",
169
+ dest="skip_version_check",
170
+ action="store_true",
171
+ help="Skips checking if the latest version is used",
172
+ )
173
+
174
+ parser.add_argument("-c", "--config", default="config.yml", help="config file path and filename")
175
+
176
+ verbosity_args = parser.add_mutually_exclusive_group()
177
+
178
+ verbosity_args.add_argument("-v", "--verbose", action="store_true", help="verbose output")
179
+
180
+ verbosity_args.add_argument(
181
+ "-d",
182
+ "--debug",
183
+ action="store_true",
184
+ help="debug output (!!! WARNING !!!: sensitive data and secrets may"
185
+ " be printed in this mode - all the data sent to GitLab API"
186
+ " will be printed in plain-text.)",
187
+ )
188
+
189
+ parser.add_argument(
190
+ "-s",
191
+ "--strict",
192
+ action="store_true",
193
+ help="stop on missing branches and tags",
194
+ )
195
+
196
+ parser.add_argument(
197
+ "-n",
198
+ "--noop",
199
+ dest="noop",
200
+ action="store_true",
201
+ help="run in no-op (dry run) mode",
202
+ )
203
+
204
+ parser.add_argument(
205
+ "-doc",
206
+ "--diff-only-changed",
207
+ dest="diff_only_changed",
208
+ action="store_true",
209
+ help="only show items who's values are changing in the diff.",
210
+ )
211
+
212
+ parser.add_argument(
213
+ "-o",
214
+ "--output-file",
215
+ dest="output_file",
216
+ default=None,
217
+ help="name/path of a file to write the effective configs to"
218
+ " (!!! WARNING !!!: if your config contains sensitive data or secrets, then this file will also"
219
+ " contain them.)",
220
+ )
221
+
222
+ parser.add_argument(
223
+ "-a",
224
+ "--include-archived-projects",
225
+ dest="include_archived_projects",
226
+ action="store_true",
227
+ help="Includes processing projects that are archived",
228
+ )
229
+
230
+ parser.add_argument(
231
+ "--include-projects-scheduled-for-deletion",
232
+ dest="include_projects_scheduled_for_deletion",
233
+ action="store_true",
234
+ help="Includes processing projects that are scheduled for deletion",
235
+ )
236
+
237
+ parser.add_argument(
238
+ "-t",
239
+ "--terminate",
240
+ dest="terminate_after_error",
241
+ action="store_true",
242
+ help=f"exit with {EXIT_PROCESSING_ERROR} after the first group/project processing error."
243
+ f" (default: process all the requested groups/projects and skip the failing ones."
244
+ f" At the end, if there were any groups/projects, exit with {EXIT_PROCESSING_ERROR}.)",
245
+ )
246
+
247
+ parser.add_argument(
248
+ "-sf",
249
+ "--start-from",
250
+ dest="start_from",
251
+ default=1,
252
+ type=int,
253
+ help="start processing projects from the given one"
254
+ ' (as numbered by "x/y Processing group/project" messages)',
255
+ )
256
+
257
+ parser.add_argument(
258
+ "-sfg",
259
+ "--start-from-group",
260
+ dest="start_from_group",
261
+ default=1,
262
+ type=int,
263
+ help="start processing groups from the given one "
264
+ '(as numbered by "x/y Processing group/project" messages)',
265
+ )
266
+
267
+ sections_group = parser.add_mutually_exclusive_group()
268
+
269
+ sections_group.add_argument(
270
+ "-os",
271
+ "--only-sections",
272
+ dest="only_sections",
273
+ default="all",
274
+ type=str,
275
+ help="process only section with these names (comma-delimited)",
276
+ )
277
+
278
+ sections_group.add_argument(
279
+ "-es",
280
+ "--exclude-sections",
281
+ dest="exclude_sections",
282
+ default="none",
283
+ type=str,
284
+ help="exclude sections with these names (comma-delimited). Warning: may result in failure or odd functionality when excluding sections that are dependent on by other sections.",
285
+ )
286
+
287
+ parser.add_argument(
288
+ "-r",
289
+ "--recurse-subgroups",
290
+ dest="recurse_subsgroups",
291
+ action="store_true",
292
+ help="include all subgroups recursively. Always true for ALL, ALL_DEFINED",
293
+ )
294
+
295
+ args = parser.parse_args()
296
+
297
+ if args.only_sections != "all":
298
+ args.only_sections = args.only_sections.split(",")
299
+
300
+ if args.exclude_sections != "none":
301
+ args.exclude_sections = args.exclude_sections.split(",")
302
+ else:
303
+ args.exclude_sections = []
304
+
305
+ return (
306
+ args.target,
307
+ args.config,
308
+ args.verbose,
309
+ args.debug,
310
+ args.strict,
311
+ args.start_from,
312
+ args.start_from_group,
313
+ args.noop,
314
+ args.diff_only_changed,
315
+ args.output_file,
316
+ args.skip_version_check,
317
+ args.include_archived_projects,
318
+ args.include_projects_scheduled_for_deletion,
319
+ args.just_show_version,
320
+ args.terminate_after_error,
321
+ args.only_sections,
322
+ args.exclude_sections,
323
+ args.recurse_subsgroups,
324
+ )
325
+
326
+ def _configure_output(self, tests=False) -> None:
327
+ """
328
+ Configures the application output using logging, based on debug and verbose flags:
329
+
330
+ * normal mode - logging only WARNING logs
331
+ * verbose mode - logging INFO level and above
332
+ * debug / tests mode - logging DEBUG level and above, along with rich exception tracebacks (may expose secrets)
333
+
334
+ :param tests: True if we are running in tests mode
335
+ """
336
+ rich_tracebacks = False
337
+ if tests or self.debug:
338
+ level = logging.DEBUG
339
+ rich_tracebacks = True
340
+ elif self.verbose:
341
+ level = logging.INFO
342
+ else:
343
+ level = logging.WARNING
344
+
345
+ logging.basicConfig(
346
+ level=level, format="%(message)s", datefmt="[%X]", handlers=[RichHandler(rich_tracebacks=rich_tracebacks)]
347
+ )
348
+
349
+ def _initialize_configuration_and_gitlab(self) -> Tuple[GitLab, Configuration]:
350
+ """
351
+ Creates the GitLab object, which represents connection to the GitLab API,
352
+ and the configuration object, which represents the YAML configuration,
353
+ and runs processing the configuration with configuration transformers.
354
+
355
+ :return: tuple with GitLab and Configuration objects
356
+ """
357
+
358
+ try:
359
+ if hasattr(self, "config_string"):
360
+ gitlab = GitLab(config_string=self.config_string)
361
+ else:
362
+ gitlab = GitLab(config_path=self.config)
363
+ configuration = gitlab.get_configuration()
364
+
365
+ configuration_transformers = ConfigurationTransformers(gitlab)
366
+ configuration_transformers.transform(configuration)
367
+
368
+ except ConfigFileNotFoundException as e:
369
+ critical(f"Config file not found at: {e}")
370
+ sys.exit(EXIT_INVALID_INPUT)
371
+ except ConfigInvalidException as e:
372
+ critical(f"Invalid config:\n{e.underlying}")
373
+ sys.exit(EXIT_INVALID_INPUT)
374
+ except TestRequestFailedException as e:
375
+ critical(f"GitLab test request failed:\n{e.underlying}")
376
+ sys.exit(EXIT_INVALID_INPUT)
377
+
378
+ return gitlab, configuration
379
+
380
+ def run(self) -> None:
381
+ """
382
+ The main method.
383
+ """
384
+
385
+ projects, groups = self._get_groups_and_projects(
386
+ self.target,
387
+ )
388
+
389
+ group_number = 0
390
+ successful_groups = 0
391
+ failed_groups = {}
392
+
393
+ effective_configuration = EffectiveConfigurationFile(self.output_file)
394
+
395
+ application_configuration = self.configuration.get("application", {})
396
+ if application_configuration:
397
+ self.application_processors.process_entity(
398
+ "",
399
+ application_configuration,
400
+ dry_run=self.noop,
401
+ diff_only_changed=self.diff_only_changed,
402
+ effective_configuration=effective_configuration,
403
+ only_sections=self.only_sections,
404
+ exclude_sections=self.exclude_sections,
405
+ )
406
+
407
+ for group in groups:
408
+ group_number += 1
409
+
410
+ if group_number < self.start_from_group:
411
+ self._info_group_count(
412
+ "@",
413
+ group_number,
414
+ len(groups),
415
+ "yellow",
416
+ f"Skipping group {group} as requested to start from {self.start_from_group}...",
417
+ )
418
+ continue
419
+
420
+ group_configuration = self.configuration.get_effective_config_for_group(group)
421
+
422
+ effective_configuration.add_placeholder(group)
423
+
424
+ self._info_group_count(
425
+ "@",
426
+ group_number,
427
+ len(groups),
428
+ "black",
429
+ f"Processing group: {group}",
430
+ )
431
+
432
+ try:
433
+ self.group_processors.process_entity(
434
+ group,
435
+ group_configuration,
436
+ dry_run=self.noop,
437
+ diff_only_changed=self.diff_only_changed,
438
+ effective_configuration=effective_configuration,
439
+ only_sections=self.only_sections,
440
+ exclude_sections=self.exclude_sections,
441
+ )
442
+
443
+ successful_groups += 1
444
+
445
+ except Exception as e:
446
+ failed_groups[group_number] = group
447
+
448
+ trace = traceback.format_exc()
449
+ message = f"Error occurred while processing group {group}, exception:\n\n{e}"
450
+
451
+ if self.terminate_after_error:
452
+ effective_configuration.write_to_file()
453
+ error(message)
454
+ debug(trace)
455
+ sys.exit(EXIT_PROCESSING_ERROR)
456
+ else:
457
+ warning(message)
458
+ debug(trace)
459
+ finally:
460
+ debug(f"@ ({group_number}/{len(groups)}) FINISHED Processing group: {group}")
461
+
462
+ project_number = 0
463
+ successful_projects = 0
464
+ failed_projects = {}
465
+
466
+ for project_and_group in projects:
467
+ project_number += 1
468
+
469
+ if project_number < self.start_from:
470
+ self._info_project_count(
471
+ "*",
472
+ project_number,
473
+ len(projects),
474
+ "yellow",
475
+ f"Skipping project {project_and_group} as requested to start from {self.start_from}...",
476
+ )
477
+ continue
478
+
479
+ project_configuration = self.configuration.get_effective_config_for_project(project_and_group)
480
+
481
+ effective_configuration.add_placeholder(project_and_group)
482
+
483
+ self._info_project_count(
484
+ "*",
485
+ project_number,
486
+ len(projects),
487
+ "black",
488
+ f"Processing project: {project_and_group}",
489
+ )
490
+
491
+ try:
492
+ self.project_processors.process_entity(
493
+ project_and_group,
494
+ project_configuration,
495
+ dry_run=self.noop,
496
+ diff_only_changed=self.diff_only_changed,
497
+ effective_configuration=effective_configuration,
498
+ only_sections=self.only_sections,
499
+ exclude_sections=self.exclude_sections,
500
+ )
501
+
502
+ successful_projects += 1
503
+
504
+ except Exception as e:
505
+ if "Non GET methods are not allowed for moved projects" in str(e):
506
+ info("Project has been transferred, no need to process original location")
507
+ continue
508
+
509
+ failed_projects[project_number] = project_and_group
510
+
511
+ trace = traceback.format_exc()
512
+ message = f"Error occurred while processing project {project_and_group}, exception:\n\n{e}"
513
+
514
+ if self.terminate_after_error:
515
+ effective_configuration.write_to_file()
516
+ error(message)
517
+ debug(trace)
518
+ sys.exit(EXIT_PROCESSING_ERROR)
519
+ else:
520
+ warning(message)
521
+ debug(trace)
522
+ finally:
523
+ debug(
524
+ f"* ({project_number}/{len(projects)})" f" FINISHED Processing project: {project_and_group}",
525
+ )
526
+
527
+ effective_configuration.write_to_file()
528
+
529
+ self._show_summary(
530
+ groups,
531
+ projects,
532
+ successful_groups,
533
+ successful_projects,
534
+ failed_groups,
535
+ failed_projects,
536
+ )
537
+
538
+ @classmethod
539
+ def _show_version(cls, skip_version_check: bool) -> None:
540
+ """
541
+ Prints the app version and how it relates to the latest stable version
542
+ available at PyPI.
543
+
544
+ :param skip_version_check: if True then the comparison to the latest versions is skipped
545
+ """
546
+
547
+ local_version = package_version("gitlabform")
548
+
549
+ version_text = ""
550
+ # Legacy windows console cannot support unicode emoji rendering via Rich
551
+ if not console.legacy_windows:
552
+ version_text = "🏗 "
553
+ version_text += f"GitLabForm version: [bold blue]{local_version}[/]"
554
+
555
+ if skip_version_check:
556
+ # just print version in use
557
+ console.print(version_text)
558
+ else:
559
+ try:
560
+ latest_version = luddite.get_version_pypi("gitlabform")
561
+ except Exception as e:
562
+ # end the line with current version
563
+ print()
564
+ error(f"Checking latest version failed:\n{e}")
565
+ return
566
+
567
+ latest_stable_text = "(the latest stable is {latest_version})"
568
+
569
+ if local_version == latest_version:
570
+ version_info = "= the latest stable"
571
+ # Legacy windows console cannot support unicode emoji rendering via Rich
572
+ if not console.legacy_windows:
573
+ version_info = f"{version_info} ☺️"
574
+ elif version.parse(local_version) < version.parse(latest_version):
575
+ version_info = f"= outdated, please update"
576
+ if not console.legacy_windows:
577
+ version_info = f"{version_info} 😔"
578
+ version_info = f"{version_info}! {latest_stable_text}"
579
+ else:
580
+ version_info = f"= pre-release: "
581
+ if not console.legacy_windows:
582
+ version_info = f"{version_info} 🤩"
583
+ version_info = f"{version_info} {latest_stable_text}"
584
+
585
+ # complete the line with a line ending
586
+ console.print(f"{version_text} {version_info}")
587
+
588
+ def _get_groups_and_projects(
589
+ self,
590
+ target: str,
591
+ ) -> Tuple[list, list]:
592
+ """
593
+ Gets the list of groups and projects to apply the configuration to, based on the provided 'target' parameter
594
+ and prints out the output header based on that.
595
+
596
+ :param target: what the configuration should be applied to
597
+ :return: a tuple of lists of effective projects and effective groups
598
+ """
599
+
600
+ if self.noop:
601
+ info("Running in dry-run mode...")
602
+
603
+ if target == "ALL":
604
+ info(">>> Getting ALL groups and projects that I have permission to modify...")
605
+ elif target == "ALL_DEFINED":
606
+ info(">>> Getting ALL groups and projects DEFINED in the configuration...")
607
+ else:
608
+ info(">>> Getting requested groups or projects...")
609
+
610
+ groups = self.groups_provider.get_groups(target)
611
+ projects = self.projects_provider.get_projects(target)
612
+
613
+ if len(groups.get_effective()) == 0 and len(projects.get_effective()) == 0:
614
+ if target == "ALL":
615
+ error_message = "GitLab has no projects and groups!"
616
+ elif target == "ALL_DEFINED":
617
+ error_message = "Configuration does not have any groups or projects defined!"
618
+ else:
619
+ error_message = f"Project or group {target} cannot be found in GitLab!"
620
+ critical(error_message)
621
+ sys.exit(EXIT_INVALID_INPUT)
622
+
623
+ self.groups_and_projects_filters.filter(groups, projects)
624
+
625
+ self._show_input_entities(groups)
626
+ self._show_input_entities(projects)
627
+
628
+ return projects.get_effective(), groups.get_effective()
629
+
630
+ @classmethod
631
+ def _show_input_entities(cls, entities: Entities) -> None:
632
+ """
633
+ Prints out the groups or projects that will be processed.
634
+
635
+ :param entities: groups or projects
636
+ """
637
+ info(f"# of {entities.name} to process: {len(entities.get_effective())}")
638
+
639
+ entities_omitted = ""
640
+ entities_verbose = f"{entities.name}: {entities.get_effective()}"
641
+ if entities.any_omitted():
642
+ entities_omitted += f"(# of omitted {entities.name} -"
643
+ first = True
644
+ for reason in entities.omitted:
645
+ if len(entities.omitted[reason]) > 0:
646
+ if not first:
647
+ entities_omitted += ","
648
+ entities_omitted += f" {reason.value}: {len(entities.omitted[reason])}"
649
+ entities_verbose += f"\nomitted {entities.name} - {reason.value}: {entities.get_omitted(reason)}"
650
+ first = False
651
+ entities_omitted += ")"
652
+
653
+ if entities_omitted:
654
+ info(entities_omitted)
655
+
656
+ info(entities_verbose)
657
+
658
+ @classmethod
659
+ def _show_summary(
660
+ cls,
661
+ effective_groups: list,
662
+ effective_projects: list,
663
+ successful_groups: int,
664
+ successful_projects: int,
665
+ failed_groups: dict,
666
+ failed_projects: dict,
667
+ ):
668
+ """
669
+ Prints out the summary after processing has ended with the info of what was done and what failed.
670
+
671
+ :param effective_groups: list of effective groups that the run was done on
672
+ :param effective_projects: list of effective projects that the run was done on
673
+ :param successful_groups: number of successfully processed groups
674
+ :param successful_projects: number of successfully processed projects
675
+ :param failed_groups: a dict with failed groups, where keys are their numbers in the processing order
676
+ :param failed_projects: a dict with failed projects, where keys are their numbers in the processing order
677
+ """
678
+
679
+ if len(effective_groups) > 0 or len(effective_projects) > 0:
680
+ info(f"# of groups processed successfully: {successful_groups}")
681
+ info(f"# of projects processed successfully: {successful_projects}")
682
+
683
+ if len(failed_groups) > 0:
684
+ console.print(f"# of groups failed: {len(failed_groups)}", style="red")
685
+ for group_number in failed_groups.keys():
686
+ console.print(f"Failed group {group_number}: {failed_groups[group_number]}", style="red")
687
+ if len(failed_projects) > 0:
688
+ console.print(f"# of projects failed: {len(failed_projects)}", style="red")
689
+ for project_number in failed_projects.keys():
690
+ console.print(f"Failed project {project_number}: {failed_projects[project_number]}", style="red")
691
+
692
+ if len(failed_groups) > 0 or len(failed_projects) > 0:
693
+ sys.exit(EXIT_PROCESSING_ERROR)
694
+ elif successful_groups > 0 or successful_projects > 0:
695
+ console.print("All requested groups/projects processed successfully! :sparkles:", style="green")
696
+ else:
697
+ console.print("Nothing to do.", style="yellow")
698
+
699
+ @classmethod
700
+ def _info_group_count(cls, prefix, i: int, n: int, second_color: str, second_text: str) -> None:
701
+ cls._info_count("purple", prefix, i, n, second_color, second_text)
702
+
703
+ @classmethod
704
+ def _info_project_count(cls, prefix, i: int, n: int, second_color: str, second_text: str) -> None:
705
+ cls._info_count("green", prefix, i, n, second_color, second_text)
706
+
707
+ @classmethod
708
+ def _info_count(cls, color: str, prefix, i: int, n: int, second_color: str, second_text: str) -> None:
709
+ num_digits = len(str(n))
710
+ counter_format = f"(%{num_digits}d/%d)"
711
+ counter_str = counter_format % (i, n)
712
+ console.print(f"[bold {color}]{prefix} {counter_str}[/] [{second_color}]{second_text}[/]")
713
+
714
+
715
+ class Formatter(
716
+ argparse.ArgumentDefaultsHelpFormatter,
717
+ argparse.RawTextHelpFormatter,
718
+ ):
719
+ pass