gitlabform 4.2.5__tar.gz → 4.3.0__tar.gz

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 (88) hide show
  1. {gitlabform-4.2.5 → gitlabform-4.3.0}/PKG-INFO +15 -15
  2. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/gitlab/python_gitlab.py +1 -0
  3. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/group/group_hooks_processor.py +2 -1
  4. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/group/group_members_processor.py +36 -32
  5. gitlabform-4.3.0/gitlabform/processors/group/group_settings_processor.py +90 -0
  6. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/project/hooks_processor.py +2 -1
  7. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/project/integrations_processor.py +10 -1
  8. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/project/members_processor.py +15 -30
  9. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/project/project_settings_processor.py +62 -1
  10. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/project/schedules_processor.py +1 -1
  11. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/util/labels_processor.py +2 -2
  12. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform.egg-info/PKG-INFO +15 -15
  13. gitlabform-4.3.0/gitlabform.egg-info/requires.txt +31 -0
  14. {gitlabform-4.2.5 → gitlabform-4.3.0}/pyproject.toml +15 -15
  15. gitlabform-4.2.5/gitlabform/processors/group/group_settings_processor.py +0 -30
  16. gitlabform-4.2.5/gitlabform.egg-info/requires.txt +0 -31
  17. {gitlabform-4.2.5 → gitlabform-4.3.0}/LICENSE +0 -0
  18. {gitlabform-4.2.5 → gitlabform-4.3.0}/README.md +0 -0
  19. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/__init__.py +0 -0
  20. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/configuration/__init__.py +0 -0
  21. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/configuration/common.py +0 -0
  22. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/configuration/core.py +0 -0
  23. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/configuration/groups.py +0 -0
  24. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/configuration/projects.py +0 -0
  25. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/configuration/transform.py +0 -0
  26. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/constants.py +0 -0
  27. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/gitlab/__init__.py +0 -0
  28. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/gitlab/commits.py +0 -0
  29. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/gitlab/core.py +0 -0
  30. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/gitlab/group_badges.py +0 -0
  31. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/gitlab/group_ldap_links.py +0 -0
  32. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/gitlab/group_variables.py +0 -0
  33. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/gitlab/groups.py +0 -0
  34. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/gitlab/merge_requests.py +0 -0
  35. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/gitlab/pipelines.py +0 -0
  36. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/gitlab/project_badges.py +0 -0
  37. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/gitlab/project_deploy_keys.py +0 -0
  38. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/gitlab/project_merge_requests_approvals.py +0 -0
  39. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/gitlab/project_protected_environments.py +0 -0
  40. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/gitlab/project_security_settings.py +0 -0
  41. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/gitlab/projects.py +0 -0
  42. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/gitlab/variables.py +0 -0
  43. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/lists/__init__.py +0 -0
  44. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/lists/filter.py +0 -0
  45. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/lists/groups.py +0 -0
  46. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/lists/projects.py +0 -0
  47. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/output.py +0 -0
  48. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/__init__.py +0 -0
  49. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/abstract_processor.py +0 -0
  50. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/application/__init__.py +0 -0
  51. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/application/application_settings_processor.py +0 -0
  52. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/defining_keys.py +0 -0
  53. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/group/__init__.py +0 -0
  54. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/group/group_badges_processor.py +0 -0
  55. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/group/group_labels_processor.py +0 -0
  56. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/group/group_ldap_links_processor.py +0 -0
  57. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/group/group_push_rules_processor.py +0 -0
  58. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/group/group_saml_links_processor.py +0 -0
  59. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/group/group_variables_processor.py +0 -0
  60. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/multiple_entities_processor.py +0 -0
  61. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/project/__init__.py +0 -0
  62. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/project/badges_processor.py +0 -0
  63. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/project/branches_processor.py +0 -0
  64. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/project/deploy_keys_processor.py +0 -0
  65. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/project/files_processor.py +0 -0
  66. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/project/job_token_scope_processor.py +0 -0
  67. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/project/merge_requests_approval_rules.py +0 -0
  68. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/project/merge_requests_approvals.py +0 -0
  69. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/project/project_labels_processor.py +0 -0
  70. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/project/project_processor.py +0 -0
  71. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/project/project_push_rules_processor.py +0 -0
  72. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/project/project_security_settings.py +0 -0
  73. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/project/resource_groups_processor.py +0 -0
  74. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/project/tags_processor.py +0 -0
  75. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/project/variables_processor.py +0 -0
  76. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/shared/__init__.py +0 -0
  77. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/shared/protected_environments_processor.py +0 -0
  78. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/single_entity_processor.py +0 -0
  79. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/util/__init__.py +0 -0
  80. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/util/decorators.py +0 -0
  81. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/processors/util/difference_logger.py +0 -0
  82. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/run.py +0 -0
  83. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform/util.py +0 -0
  84. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform.egg-info/SOURCES.txt +0 -0
  85. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform.egg-info/dependency_links.txt +0 -0
  86. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform.egg-info/entry_points.txt +0 -0
  87. {gitlabform-4.2.5 → gitlabform-4.3.0}/gitlabform.egg-info/top_level.txt +0 -0
  88. {gitlabform-4.2.5 → gitlabform-4.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitlabform
3
- Version: 4.2.5
3
+ Version: 4.3.0
4
4
  Summary: 🏗 Specialized configuration as a code tool for GitLab projects, groups and more using hierarchical configuration written in YAML
5
5
  Author: Greg Dubicki and Contributors
6
6
  Project-URL: Homepage, https://gitlabform.github.io/gitlabform/
@@ -20,7 +20,7 @@ Classifier: Topic :: Software Development :: Version Control :: Git
20
20
  Requires-Python: >=3.12.0
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
- Requires-Dist: certifi==2025.1.31
23
+ Requires-Dist: certifi==2025.7.14
24
24
  Requires-Dist: cli-ui==0.19.0
25
25
  Requires-Dist: ez-yaml==1.2.0
26
26
  Requires-Dist: Jinja2==3.1.6
@@ -28,23 +28,23 @@ Requires-Dist: luddite==1.0.4
28
28
  Requires-Dist: MarkupSafe==3.0.2
29
29
  Requires-Dist: mergedeep==1.3.4
30
30
  Requires-Dist: packaging==25.0
31
- Requires-Dist: python-gitlab==5.6.0
32
- Requires-Dist: python-gitlab[graphql]==5.6.0
33
- Requires-Dist: requests==2.32.3
31
+ Requires-Dist: python-gitlab==6.1.0
32
+ Requires-Dist: python-gitlab[graphql]==6.1.0
33
+ Requires-Dist: requests==2.32.4
34
34
  Requires-Dist: ruamel.yaml==0.17.21
35
- Requires-Dist: types-requests==2.32.0.20250328
36
- Requires-Dist: types-setuptools==78.1.0.20250329
35
+ Requires-Dist: types-requests==2.32.4.20250611
36
+ Requires-Dist: types-setuptools==80.9.0.20250529
37
37
  Requires-Dist: yamlpath==3.8.2
38
38
  Provides-Extra: test
39
- Requires-Dist: coverage==7.8.0; extra == "test"
40
- Requires-Dist: cryptography==44.0.2; extra == "test"
41
- Requires-Dist: deepdiff==8.4.2; extra == "test"
42
- Requires-Dist: mypy==1.15.0; extra == "test"
43
- Requires-Dist: mypy-extensions==1.0.0; extra == "test"
39
+ Requires-Dist: coverage==7.9.2; extra == "test"
40
+ Requires-Dist: cryptography==45.0.5; extra == "test"
41
+ Requires-Dist: deepdiff==8.5.0; extra == "test"
42
+ Requires-Dist: mypy==1.17.0; extra == "test"
43
+ Requires-Dist: mypy-extensions==1.1.0; extra == "test"
44
44
  Requires-Dist: pre-commit==2.21.0; extra == "test"
45
- Requires-Dist: pytest==8.3.5; extra == "test"
46
- Requires-Dist: pytest-cov==6.1.1; extra == "test"
47
- Requires-Dist: pytest-rerunfailures==15.0; extra == "test"
45
+ Requires-Dist: pytest==8.4.1; extra == "test"
46
+ Requires-Dist: pytest-cov==6.2.1; extra == "test"
47
+ Requires-Dist: pytest-rerunfailures==15.1; extra == "test"
48
48
  Requires-Dist: xkcdpass==1.20.0; extra == "test"
49
49
  Provides-Extra: docs
50
50
  Requires-Dist: mkdocs; extra == "docs"
@@ -96,6 +96,7 @@ class PythonGitlab(Gitlab):
96
96
  # Gitlab API will only ever return 0 or 1 entry when GETting using `username` attribute
97
97
  # https://docs.gitlab.com/ee/api/users.html#for-non-administrator-users
98
98
  # so will always be list[RESTObject] and never RESTObjectList from python-gitlab's api
99
+ # username list is case-insensitive on the gitlab api
99
100
  users: list[RESTObject] = self.users.list(username=username) # type: ignore
100
101
 
101
102
  if len(users) == 0:
@@ -3,6 +3,7 @@ from typing import Dict, Any, List
3
3
 
4
4
  from gitlab.base import RESTObject, RESTObjectList
5
5
  from gitlab.v4.objects import Group
6
+ from gitlab.v4.objects import GroupHook
6
7
 
7
8
  from gitlabform.gitlab import GitLab
8
9
  from gitlabform.processors.abstract_processor import AbstractProcessor
@@ -15,7 +16,7 @@ class GroupHooksProcessor(AbstractProcessor):
15
16
  def _process_configuration(self, group_path_and_name: str, configuration: dict):
16
17
  debug("Processing group hooks...")
17
18
  group: Group = self.gl.get_group_by_path_cached(group_path_and_name)
18
- group_hooks: RESTObjectList | List[RESTObject] = group.hooks.list(get_all=True)
19
+ group_hooks: list[GroupHook] = group.hooks.list(get_all=True)
19
20
 
20
21
  hooks_in_config: tuple[str, ...] = tuple(x for x in sorted(configuration["group_hooks"]) if x != "enforce")
21
22
 
@@ -1,14 +1,12 @@
1
- from logging import debug, warning
2
1
  from typing import Dict, Tuple
3
2
 
4
- import gitlab
5
- from cli_ui import fatal, error, debug as verbose
3
+ from cli_ui import info, fatal, error, debug as verbose
6
4
 
7
5
  from gitlabform.constants import EXIT_INVALID_INPUT
8
6
  from gitlabform.gitlab import GitLab, AccessLevel
9
7
  from gitlabform.processors.abstract_processor import AbstractProcessor
10
8
  from gitlab.v4.objects import Group, GroupMember, User
11
- from gitlab import GitlabDeleteError, GitlabGetError
9
+ from gitlab import GitlabDeleteError, GitlabError, GitlabGetError
12
10
 
13
11
 
14
12
  class GroupMembersProcessor(AbstractProcessor):
@@ -68,7 +66,7 @@ class GroupMembersProcessor(AbstractProcessor):
68
66
  enforce_group_members: bool,
69
67
  ):
70
68
  shared_with_groups_before = group_being_processed.shared_with_groups
71
- debug("Group shared with BEFORE: %s", shared_with_groups_before)
69
+ verbose("Group shared with BEFORE: %s", shared_with_groups_before)
72
70
 
73
71
  groups_before_by_group_path = dict()
74
72
  for shared_with_group in shared_with_groups_before:
@@ -88,45 +86,49 @@ class GroupMembersProcessor(AbstractProcessor):
88
86
  expires_at_before = groups_before_by_group_path[share_with_group_path]["expires_at"]
89
87
 
90
88
  if group_access_before == group_access_to_set and expires_at_before == expires_at_to_set:
91
- debug(
89
+ verbose(
92
90
  "Nothing to change for group '%s' - same config now as to set.",
93
91
  share_with_group_path,
94
92
  )
95
93
  else:
96
- debug(
97
- "Re-adding group '%s' to change their access level or expires at.",
98
- share_with_group_path,
99
- )
94
+ info(f"Re-adding group {share_with_group_path} to change their access level or expires at.")
100
95
  share_with_group_id = groups_before_by_group_path[share_with_group_path]["group_id"]
101
96
  # we will remove the group first and then re-add them,
102
97
  # to ensure that the group has the expected access level
103
98
  self._unshare(group_being_processed, share_with_group_id)
104
99
 
105
- group_being_processed.share(share_with_group_id, group_access_to_set, expires_at_to_set)
100
+ try:
101
+ group_being_processed.share(share_with_group_id, group_access_to_set, expires_at_to_set)
102
+ except GitlabError as e:
103
+ error(f"Error processing {share_with_group_path}, {e.error_message}")
104
+ raise e
106
105
 
107
106
  else:
108
- debug(
109
- "Adding group '%s' who previously was not a member.",
110
- share_with_group_path,
107
+ verbose(
108
+ f"Adding group {share_with_group_path} who previously was not a member.",
111
109
  )
112
110
 
113
111
  share_with_group_id = self.gl.get_group_id(share_with_group_path)
114
- group_being_processed.share(share_with_group_id, group_access_to_set, expires_at_to_set)
112
+ try:
113
+ group_being_processed.share(share_with_group_id, group_access_to_set, expires_at_to_set)
114
+ except GitlabError as e:
115
+ error(f"Error processing {share_with_group_path}, {e.error_message}")
116
+ raise e
115
117
 
116
118
  if enforce_group_members:
117
119
  # remove groups not configured explicitly
118
120
  groups_not_configured = set(groups_before_by_group_path) - set(groups_to_share_with_by_path)
119
121
  for group_path in groups_not_configured:
120
- debug(
122
+ verbose(
121
123
  "Removing group '%s' who is not configured to be a member.",
122
124
  group_path,
123
125
  )
124
126
  share_with_group_id = self.gl.get_group_id(group_path)
125
127
  self._unshare(group_being_processed, share_with_group_id)
126
128
  else:
127
- debug("Not enforcing group members.")
129
+ verbose("Not enforcing group members.")
128
130
 
129
- debug(
131
+ verbose(
130
132
  "Group shared with AFTER: %s",
131
133
  group_being_processed.members.list(get_all=True),
132
134
  )
@@ -136,7 +138,7 @@ class GroupMembersProcessor(AbstractProcessor):
136
138
  try:
137
139
  group_being_processed.unshare(share_with_group_id)
138
140
  except GitlabDeleteError:
139
- debug("Group could not be unshared, likely was never shared to begin with")
141
+ info(f"Group with id {share_with_group_id} could not be unshared, likely was never shared to begin with")
140
142
  pass
141
143
 
142
144
  def _process_users(
@@ -150,7 +152,7 @@ class GroupMembersProcessor(AbstractProcessor):
150
152
  # (note: we DON'T get inherited users as we don't manage them at this level anyway)
151
153
  users_before = self.get_group_members(group)
152
154
 
153
- debug("Group members BEFORE: %s", users_before.keys())
155
+ verbose("Group members BEFORE: %s", users_before.keys())
154
156
 
155
157
  if users_to_set_by_username:
156
158
  # group users to set by access level
@@ -210,26 +212,28 @@ class GroupMembersProcessor(AbstractProcessor):
210
212
  and expires_at_before == expires_at_to_set
211
213
  and member_role_id_before == member_role_id_to_set
212
214
  ):
213
- debug(
215
+ verbose(
214
216
  "Nothing to change for user '%s' - same config now as to set.",
215
217
  common_username,
216
218
  )
217
219
  else:
218
- debug(
219
- "Editing user '%s' membership to change their access level or expires at.",
220
- common_username,
220
+ verbose(
221
+ f"Editing user {common_username} to change their access level to {access_level_to_set},"
222
+ f" expires at to {expires_at_to_set},"
223
+ f" and member_role_id to {member_role_id_to_set}."
221
224
  )
222
225
 
223
226
  group_member.access_level = access_level_to_set
224
227
  group_member.expires_at = expires_at_to_set
225
228
  group_member.member_role_id = member_role_id_to_set
226
- group_member.save()
229
+ try:
230
+ group_member.save()
231
+ except GitlabError as e:
232
+ error(f"Could not save user {common_username}, error: {e.error_message}")
233
+ raise e
227
234
 
228
235
  else:
229
- debug(
230
- "Adding user '%s' who previously was not a member.",
231
- common_username,
232
- )
236
+ verbose(f"Adding user {common_username} who previously was not a member.")
233
237
  group.members.create(
234
238
  {
235
239
  "user_id": user_id,
@@ -259,7 +263,7 @@ class GroupMembersProcessor(AbstractProcessor):
259
263
  continue
260
264
 
261
265
  if keep_bots and gl_user.bot:
262
- debug(f"Will not remove bot user '{user}' as the 'keep_bots' option is true.")
266
+ verbose(f"Will not remove bot user '{user}' as the 'keep_bots' option is true.")
263
267
  continue
264
268
 
265
269
  try:
@@ -269,9 +273,9 @@ class GroupMembersProcessor(AbstractProcessor):
269
273
  raise delete_error
270
274
 
271
275
  else:
272
- debug("Not enforcing group members.")
276
+ verbose("Not enforcing group members.")
273
277
 
274
- debug(f"Group members AFTER: {group.members.list(get_all=True)}")
278
+ verbose(f"Group members AFTER: {group.members.list(get_all=True)}")
275
279
 
276
280
  @staticmethod
277
281
  def get_group_members(group) -> dict:
@@ -0,0 +1,90 @@
1
+ import os
2
+ from logging import info, debug, warning
3
+ from typing import Dict
4
+
5
+ from gitlabform.gitlab import GitLab
6
+ from gitlab.v4.objects.groups import Group
7
+ from gitlabform.processors.abstract_processor import AbstractProcessor
8
+
9
+
10
+ class GroupSettingsProcessor(AbstractProcessor):
11
+ def __init__(self, gitlab: GitLab):
12
+ super().__init__("group_settings", gitlab)
13
+
14
+ def _process_configuration(self, group: str, configuration: Dict):
15
+ configured_group_settings = configuration.get("group_settings", {})
16
+
17
+ gitlab_group: Group = self.gl.get_group_by_path_cached(group)
18
+
19
+ # Remove avatar from config to process it last
20
+ avatar_config = configured_group_settings.pop("avatar", None)
21
+
22
+ # Process other settings first
23
+ if self._needs_update(gitlab_group.asdict(), configured_group_settings):
24
+ info(f"Updating group settings for group {gitlab_group.name}")
25
+ self.update_group_settings(gitlab_group, configured_group_settings)
26
+ else:
27
+ debug("No update needed for Group Settings")
28
+
29
+ # Process avatar last - with error handling that doesn't stop execution
30
+ if avatar_config is not None:
31
+ try:
32
+ self._process_group_avatar(gitlab_group, {"avatar": avatar_config})
33
+ except Exception as e:
34
+ warning(f"Failed to process group avatar: {e}")
35
+ raise e
36
+
37
+ @staticmethod
38
+ def update_group_settings(gitlab_group: Group, group_settings_config: dict):
39
+ for key in group_settings_config:
40
+ value = group_settings_config[key]
41
+ debug(f"Updating setting {key} to value {value}")
42
+ gitlab_group.__setattr__(key, value)
43
+ gitlab_group.save()
44
+
45
+ def _process_group_avatar(self, gitlab_group: Group, group_settings_config: dict) -> None:
46
+ """Process group avatar settings from configuration."""
47
+ debug("Processing group avatar configuration")
48
+
49
+ avatar_path = group_settings_config.get("avatar")
50
+ if avatar_path is None:
51
+ debug("No avatar configuration provided, skipping avatar processing")
52
+ return
53
+
54
+ debug(f"Avatar configuration found: {avatar_path}")
55
+
56
+ # Check current avatar status
57
+ current_avatar = getattr(gitlab_group, "avatar_url", None)
58
+
59
+ if avatar_path == "":
60
+ # Want to remove avatar
61
+ if not current_avatar:
62
+ debug("Avatar already empty, no update needed")
63
+ return
64
+ debug("Deleting group avatar")
65
+ gitlab_group.avatar = ""
66
+ gitlab_group.save()
67
+ debug("Avatar deleted successfully")
68
+ return
69
+
70
+ # Resolve relative paths to absolute paths
71
+ if not os.path.isabs(avatar_path):
72
+ # Convert relative path to absolute path relative to current working directory
73
+ avatar_path = os.path.abspath(avatar_path)
74
+ debug(f"Resolved relative path to absolute path: {avatar_path}")
75
+
76
+ # Want to set avatar from file
77
+ debug(f"Setting group avatar from file: {avatar_path}")
78
+ try:
79
+ with open(avatar_path, "rb") as avatar_file:
80
+ gitlab_group.avatar = avatar_file
81
+ gitlab_group.save()
82
+ debug("Group avatar uploaded successfully")
83
+ except FileNotFoundError:
84
+ error_msg = f"Group avatar file not found: {avatar_path}"
85
+ debug(error_msg)
86
+ raise FileNotFoundError(error_msg)
87
+ except Exception as e:
88
+ error_msg = f"Error uploading group avatar: {str(e)}"
89
+ debug(error_msg)
90
+ raise Exception(error_msg) from e
@@ -3,6 +3,7 @@ from typing import Dict, Any, List
3
3
 
4
4
  from gitlab.base import RESTObject, RESTObjectList
5
5
  from gitlab.v4.objects import Project
6
+ from gitlab.v4.objects import ProjectHook
6
7
 
7
8
  from gitlabform.gitlab import GitLab
8
9
  from gitlabform.processors.abstract_processor import AbstractProcessor
@@ -15,7 +16,7 @@ class HooksProcessor(AbstractProcessor):
15
16
  def _process_configuration(self, project_and_group: str, configuration: dict):
16
17
  debug("Processing hooks...")
17
18
  project: Project = self.gl.get_project_by_path_cached(project_and_group)
18
- project_hooks: RESTObjectList | List[RESTObject] = project.hooks.list(get_all=True)
19
+ project_hooks: list[ProjectHook] = project.hooks.list(get_all=True)
19
20
 
20
21
  hooks_in_config: tuple[str, ...] = tuple(x for x in sorted(configuration["hooks"]) if x != "enforce")
21
22
 
@@ -1,5 +1,6 @@
1
1
  from cli_ui import debug as verbose
2
2
 
3
+ from gitlab.exceptions import GitlabDeleteError
3
4
  from gitlab.v4.objects import Project, ProjectIntegration
4
5
  from gitlabform.gitlab import GitLab
5
6
  from gitlabform.processors.abstract_processor import AbstractProcessor
@@ -18,7 +19,15 @@ class IntegrationsProcessor(AbstractProcessor):
18
19
 
19
20
  if configured_integrations[integration].get("delete"):
20
21
  verbose(f"Deleting integration: {integration}")
21
- gl_integration.delete()
22
+ try:
23
+ gl_integration.delete()
24
+ except GitlabDeleteError as e:
25
+ # If we get a 404 the integration does not exist, so we can ignore the error
26
+ if e.response_code == 404:
27
+ verbose(f"Integration {integration} does not exist, skipping deletion.")
28
+ else:
29
+ verbose(f"Failed to delete integration {integration}: {e}")
30
+ raise
22
31
  else:
23
32
  verbose(f"Setting integration: {integration}")
24
33
  project.integrations.update(integration, configured_integrations[integration])
@@ -49,15 +49,10 @@ class MembersProcessor(AbstractProcessor):
49
49
  and expires_at == current_groups[common_group_name]["expires_at"]
50
50
  and access_level == current_groups[common_group_name]["group_access_level"]
51
51
  ):
52
- verbose(
53
- "Ignoring group '%s' as it is already a member",
54
- common_group_name,
55
- )
56
- verbose(
57
- "Current settings for '%s' are: %s" % (common_group_name, current_groups[common_group_name])
58
- )
52
+ verbose(f"Ignoring group '{common_group_name}' as it is already a member")
53
+ verbose(f"Current settings for '{common_group_name}' are: {current_groups[common_group_name]}")
59
54
  else:
60
- verbose("Setting group '%s' as a member", common_group_name)
55
+ verbose(f"Setting group '{common_group_name}' as a member")
61
56
  access = access_level
62
57
  expiry = expires_at
63
58
 
@@ -86,7 +81,6 @@ class MembersProcessor(AbstractProcessor):
86
81
  enforce_members: bool,
87
82
  keep_bots: bool,
88
83
  ):
89
-
90
84
  project: Project = self.gl.get_project_by_path_cached(project_and_group)
91
85
 
92
86
  current_members = self._get_members_from_project(project)
@@ -97,10 +91,11 @@ class MembersProcessor(AbstractProcessor):
97
91
  for user in users:
98
92
  info(f"Processing user '{user}'...")
99
93
 
100
- user_id = self.gl.get_user_id_cached(user)
101
- if user_id is None:
94
+ gitlab_user = self.gl.get_user_by_username_cached(user)
95
+ if gitlab_user is None:
102
96
  warning(f"Could not find user '{user}' in Gitlab, skipping...")
103
97
  continue
98
+ user_id = gitlab_user.id
104
99
 
105
100
  expires_at = users[user]["expires_at"].strftime("%Y-%m-%d") if "expires_at" in users[user] else None
106
101
  access_level = users[user]["access_level"] if "access_level" in users[user] else None
@@ -128,33 +123,23 @@ class MembersProcessor(AbstractProcessor):
128
123
  and access_level == current_member.access_level
129
124
  and member_role_id == member_role_id_before
130
125
  ):
131
- verbose(
132
- "Nothing to change for user '%s' - same config now as to set.",
133
- common_username,
134
- )
135
- verbose(
136
- "Current settings for '%s' are: %s" % (common_username, current_members[common_username])
137
- )
126
+ verbose(f"Nothing to change for user '{common_username}' - same config now as to set.")
127
+ verbose(f"Current settings for '{common_username}' are: {current_members[common_username]}")
138
128
  else:
139
129
  verbose(
140
- "Editing user '%s' membership to change their access level or expires at",
141
- common_username,
130
+ f"Editing user '{common_username}' membership to change their access level or expires at",
142
131
  )
143
- update_data = {
144
- "user_id": common_username,
145
- "access_level": access_level,
146
- "member_role_id": member_role_id_before,
147
- }
148
132
 
133
+ project_member = project.members.get(id=user_id)
134
+ project_member.access_level = access_level
135
+ project_member.member_role_id = member_role_id_before
149
136
  if expires_at:
150
- update_data["expires_at"] = expires_at
151
-
152
- project.members.update(new_data=update_data)
137
+ project_member.expires_at = expires_at
138
+ project_member.save()
153
139
 
154
140
  else:
155
141
  verbose(
156
- "Adding user '%s' who previously was not a member.",
157
- common_username,
142
+ f"Adding user '{common_username}' who previously was not a member.",
158
143
  )
159
144
  create_data = {
160
145
  "user_id": user_id,
@@ -1,4 +1,5 @@
1
- from logging import debug
1
+ import os
2
+ from logging import debug, error, warning
2
3
  from typing import Callable, Dict, List
3
4
 
4
5
  from gitlab.v4.objects import Project
@@ -24,6 +25,10 @@ class ProjectSettingsProcessor(AbstractProcessor):
24
25
 
25
26
  self._process_project_topics(project_settings_in_config, project_settings_in_gitlab)
26
27
 
28
+ # Remove avatar from config to process it last
29
+ avatar_config = project_settings_in_config.pop("avatar", None)
30
+
31
+ # Process other settings first
27
32
  if self._needs_update(project_settings_in_gitlab, project_settings_in_config):
28
33
  debug("Updating project settings")
29
34
  for key, value in project_settings_in_config.items():
@@ -37,7 +42,16 @@ class ProjectSettingsProcessor(AbstractProcessor):
37
42
  else:
38
43
  debug("No update needed for project settings")
39
44
 
45
+ # Process avatar last - with error handling that doesn't stop execution
46
+ if avatar_config is not None:
47
+ try:
48
+ self._process_project_avatar(project, {"avatar": avatar_config})
49
+ except Exception as e:
50
+ warning(f"Failed to process project avatar: {e}")
51
+ raise e
52
+
40
53
  def get_project_settings(self, project_path: str):
54
+ """Get project settings from GitLab."""
41
55
  return self.gl.get_project_by_path_cached(project_path).asdict()
42
56
 
43
57
  def _print_diff(self, project_or_project_and_group: str, entity_config, diff_only_changed: bool):
@@ -94,3 +108,50 @@ class ProjectSettingsProcessor(AbstractProcessor):
94
108
  debug(f"topics after adjustment: {adjusted_project_topics_to_set}")
95
109
 
96
110
  project_settings_in_config["topics"] = adjusted_project_topics_to_set
111
+
112
+ def _process_project_avatar(self, project: Project, project_settings_in_config: dict) -> None:
113
+ """Process project avatar settings from configuration."""
114
+ debug("Processing project avatar configuration")
115
+
116
+ avatar_path = project_settings_in_config.get("avatar")
117
+ if avatar_path is None:
118
+ debug("No avatar configuration provided, skipping avatar processing")
119
+ return
120
+
121
+ debug(f"Avatar configuration found: {avatar_path}")
122
+
123
+ # Check current avatar status
124
+ current_avatar = getattr(project, "avatar_url", None)
125
+
126
+ if avatar_path == "":
127
+ # Want to remove avatar
128
+ if not current_avatar:
129
+ debug("Avatar already empty, no update needed")
130
+ return
131
+ debug("Deleting project avatar")
132
+ project.avatar = ""
133
+ project.save()
134
+ debug("Project avatar deleted successfully")
135
+ return
136
+
137
+ # Resolve relative paths to absolute paths
138
+ if not os.path.isabs(avatar_path):
139
+ # Convert relative path to absolute path relative to current working directory
140
+ avatar_path = os.path.abspath(avatar_path)
141
+ debug(f"Resolved relative path to absolute path: {avatar_path}")
142
+
143
+ # Want to set avatar from file
144
+ debug(f"Setting project avatar from file: {avatar_path}")
145
+ try:
146
+ with open(avatar_path, "rb") as avatar_file:
147
+ project.avatar = avatar_file
148
+ project.save()
149
+ debug("Project avatar uploaded successfully")
150
+ except FileNotFoundError:
151
+ error_msg = f"Project avatar file not found: {avatar_path}"
152
+ error(error_msg)
153
+ raise FileNotFoundError(error_msg)
154
+ except Exception as e:
155
+ error_msg = f"Error uploading project avatar: {str(e)}"
156
+ error(error_msg)
157
+ raise Exception(error_msg) from e
@@ -24,7 +24,7 @@ class SchedulesProcessor(AbstractProcessor):
24
24
  configured_schedules.pop("enforce")
25
25
 
26
26
  project: Project = self.gl.get_project_by_path_cached(project_and_group)
27
- existing_schedules: List[RESTObject] | RESTObjectList = project.pipelineschedules.list(get_all=True)
27
+ existing_schedules: list[ProjectPipelineSchedule] = project.pipelineschedules.list(get_all=True)
28
28
 
29
29
  schedule_ids_by_description: Dict = self._group_schedule_ids_by_description(existing_schedules)
30
30
 
@@ -1,5 +1,5 @@
1
1
  from cli_ui import debug as verbose, info, warning
2
- from typing import Dict, List, Callable
2
+ from typing import Dict, List, Callable, Union
3
3
 
4
4
  from gitlab.base import RESTObjectList, RESTObject
5
5
  from gitlab.v4.objects import Group, Project, ProjectLabel, GroupLabel
@@ -103,7 +103,7 @@ class LabelsProcessor:
103
103
  group_or_project: Group | Project,
104
104
  label_key: str,
105
105
  parent_object_type: str,
106
- existing_group_and_parent_labels: RESTObjectList | list[RESTObject],
106
+ existing_group_and_parent_labels: Union[List[GroupLabel], List[ProjectLabel]],
107
107
  ):
108
108
  label = configured_labels.get(label_key)
109
109
  configured_label_name = label.get("name")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitlabform
3
- Version: 4.2.5
3
+ Version: 4.3.0
4
4
  Summary: 🏗 Specialized configuration as a code tool for GitLab projects, groups and more using hierarchical configuration written in YAML
5
5
  Author: Greg Dubicki and Contributors
6
6
  Project-URL: Homepage, https://gitlabform.github.io/gitlabform/
@@ -20,7 +20,7 @@ Classifier: Topic :: Software Development :: Version Control :: Git
20
20
  Requires-Python: >=3.12.0
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
- Requires-Dist: certifi==2025.1.31
23
+ Requires-Dist: certifi==2025.7.14
24
24
  Requires-Dist: cli-ui==0.19.0
25
25
  Requires-Dist: ez-yaml==1.2.0
26
26
  Requires-Dist: Jinja2==3.1.6
@@ -28,23 +28,23 @@ Requires-Dist: luddite==1.0.4
28
28
  Requires-Dist: MarkupSafe==3.0.2
29
29
  Requires-Dist: mergedeep==1.3.4
30
30
  Requires-Dist: packaging==25.0
31
- Requires-Dist: python-gitlab==5.6.0
32
- Requires-Dist: python-gitlab[graphql]==5.6.0
33
- Requires-Dist: requests==2.32.3
31
+ Requires-Dist: python-gitlab==6.1.0
32
+ Requires-Dist: python-gitlab[graphql]==6.1.0
33
+ Requires-Dist: requests==2.32.4
34
34
  Requires-Dist: ruamel.yaml==0.17.21
35
- Requires-Dist: types-requests==2.32.0.20250328
36
- Requires-Dist: types-setuptools==78.1.0.20250329
35
+ Requires-Dist: types-requests==2.32.4.20250611
36
+ Requires-Dist: types-setuptools==80.9.0.20250529
37
37
  Requires-Dist: yamlpath==3.8.2
38
38
  Provides-Extra: test
39
- Requires-Dist: coverage==7.8.0; extra == "test"
40
- Requires-Dist: cryptography==44.0.2; extra == "test"
41
- Requires-Dist: deepdiff==8.4.2; extra == "test"
42
- Requires-Dist: mypy==1.15.0; extra == "test"
43
- Requires-Dist: mypy-extensions==1.0.0; extra == "test"
39
+ Requires-Dist: coverage==7.9.2; extra == "test"
40
+ Requires-Dist: cryptography==45.0.5; extra == "test"
41
+ Requires-Dist: deepdiff==8.5.0; extra == "test"
42
+ Requires-Dist: mypy==1.17.0; extra == "test"
43
+ Requires-Dist: mypy-extensions==1.1.0; extra == "test"
44
44
  Requires-Dist: pre-commit==2.21.0; extra == "test"
45
- Requires-Dist: pytest==8.3.5; extra == "test"
46
- Requires-Dist: pytest-cov==6.1.1; extra == "test"
47
- Requires-Dist: pytest-rerunfailures==15.0; extra == "test"
45
+ Requires-Dist: pytest==8.4.1; extra == "test"
46
+ Requires-Dist: pytest-cov==6.2.1; extra == "test"
47
+ Requires-Dist: pytest-rerunfailures==15.1; extra == "test"
48
48
  Requires-Dist: xkcdpass==1.20.0; extra == "test"
49
49
  Provides-Extra: docs
50
50
  Requires-Dist: mkdocs; extra == "docs"
@@ -0,0 +1,31 @@
1
+ certifi==2025.7.14
2
+ cli-ui==0.19.0
3
+ ez-yaml==1.2.0
4
+ Jinja2==3.1.6
5
+ luddite==1.0.4
6
+ MarkupSafe==3.0.2
7
+ mergedeep==1.3.4
8
+ packaging==25.0
9
+ python-gitlab==6.1.0
10
+ python-gitlab[graphql]==6.1.0
11
+ requests==2.32.4
12
+ ruamel.yaml==0.17.21
13
+ types-requests==2.32.4.20250611
14
+ types-setuptools==80.9.0.20250529
15
+ yamlpath==3.8.2
16
+
17
+ [docs]
18
+ mkdocs
19
+ mkdocs-material
20
+
21
+ [test]
22
+ coverage==7.9.2
23
+ cryptography==45.0.5
24
+ deepdiff==8.5.0
25
+ mypy==1.17.0
26
+ mypy-extensions==1.1.0
27
+ pre-commit==2.21.0
28
+ pytest==8.4.1
29
+ pytest-cov==6.2.1
30
+ pytest-rerunfailures==15.1
31
+ xkcdpass==1.20.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gitlabform"
7
- version = "4.2.5"
7
+ version = "4.3.0"
8
8
  authors = [{ name = "Greg Dubicki and Contributors" }]
9
9
  description = "🏗 Specialized configuration as a code tool for GitLab projects, groups and more using hierarchical configuration written in YAML"
10
10
  keywords = ["cli", "yaml", "gitlab", "configuration-as-code"]
@@ -23,7 +23,7 @@ classifiers = [
23
23
  ]
24
24
  requires-python = ">=3.12.0"
25
25
  dependencies = [
26
- "certifi==2025.1.31",
26
+ "certifi==2025.7.14",
27
27
  "cli-ui==0.19.0",
28
28
  "ez-yaml==1.2.0",
29
29
  "Jinja2==3.1.6",
@@ -31,12 +31,12 @@ dependencies = [
31
31
  "MarkupSafe==3.0.2",
32
32
  "mergedeep==1.3.4",
33
33
  "packaging==25.0",
34
- "python-gitlab==5.6.0",
35
- "python-gitlab[graphql]==5.6.0",
36
- "requests==2.32.3",
34
+ "python-gitlab==6.1.0",
35
+ "python-gitlab[graphql]==6.1.0",
36
+ "requests==2.32.4",
37
37
  "ruamel.yaml==0.17.21",
38
- "types-requests==2.32.0.20250328",
39
- "types-setuptools==78.1.0.20250329",
38
+ "types-requests==2.32.4.20250611",
39
+ "types-setuptools==80.9.0.20250529",
40
40
  "yamlpath==3.8.2",
41
41
  ]
42
42
 
@@ -52,15 +52,15 @@ content-type = "text/markdown"
52
52
 
53
53
  [project.optional-dependencies]
54
54
  test = [
55
- "coverage==7.8.0",
56
- "cryptography==44.0.2",
57
- "deepdiff==8.4.2",
58
- "mypy==1.15.0",
59
- "mypy-extensions==1.0.0",
55
+ "coverage==7.9.2",
56
+ "cryptography==45.0.5",
57
+ "deepdiff==8.5.0",
58
+ "mypy==1.17.0",
59
+ "mypy-extensions==1.1.0",
60
60
  "pre-commit==2.21.0",
61
- "pytest==8.3.5",
62
- "pytest-cov==6.1.1",
63
- "pytest-rerunfailures==15.0",
61
+ "pytest==8.4.1",
62
+ "pytest-cov==6.2.1",
63
+ "pytest-rerunfailures==15.1",
64
64
  "xkcdpass==1.20.0",
65
65
  ]
66
66
  docs = ["mkdocs", "mkdocs-material"]
@@ -1,30 +0,0 @@
1
- from logging import info, debug
2
- from typing import Dict
3
-
4
- from gitlabform.gitlab import GitLab
5
- from gitlab.v4.objects.groups import Group
6
- from gitlabform.processors.abstract_processor import AbstractProcessor
7
-
8
-
9
- class GroupSettingsProcessor(AbstractProcessor):
10
- def __init__(self, gitlab: GitLab):
11
- super().__init__("group_settings", gitlab)
12
-
13
- def _process_configuration(self, group: str, configuration: Dict):
14
- configured_group_settings = configuration.get("group_settings", {})
15
-
16
- gitlab_group: Group = self.gl.get_group_by_path_cached(group)
17
-
18
- if self._needs_update(gitlab_group.asdict(), configured_group_settings):
19
- info(f"Updating group settings for group {gitlab_group.name}")
20
- self.update_group_settings(gitlab_group, configured_group_settings)
21
- else:
22
- debug("No update needed for Group Settings")
23
-
24
- @staticmethod
25
- def update_group_settings(gitlab_group: Group, group_settings_config: dict):
26
- for key in group_settings_config:
27
- value = group_settings_config[key]
28
- debug(f"Updating setting {key} to value {value}")
29
- gitlab_group.__setattr__(key, value)
30
- gitlab_group.save()
@@ -1,31 +0,0 @@
1
- certifi==2025.1.31
2
- cli-ui==0.19.0
3
- ez-yaml==1.2.0
4
- Jinja2==3.1.6
5
- luddite==1.0.4
6
- MarkupSafe==3.0.2
7
- mergedeep==1.3.4
8
- packaging==25.0
9
- python-gitlab==5.6.0
10
- python-gitlab[graphql]==5.6.0
11
- requests==2.32.3
12
- ruamel.yaml==0.17.21
13
- types-requests==2.32.0.20250328
14
- types-setuptools==78.1.0.20250329
15
- yamlpath==3.8.2
16
-
17
- [docs]
18
- mkdocs
19
- mkdocs-material
20
-
21
- [test]
22
- coverage==7.8.0
23
- cryptography==44.0.2
24
- deepdiff==8.4.2
25
- mypy==1.15.0
26
- mypy-extensions==1.0.0
27
- pre-commit==2.21.0
28
- pytest==8.3.5
29
- pytest-cov==6.1.1
30
- pytest-rerunfailures==15.0
31
- xkcdpass==1.20.0
File without changes
File without changes
File without changes
File without changes