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
@@ -0,0 +1,12 @@
1
+ from gitlabform.configuration.common import ConfigurationCommon
2
+ from gitlabform.configuration.groups import ConfigurationGroups
3
+ from gitlabform.configuration.projects import ConfigurationProjects
4
+
5
+ #
6
+ # This the only external interface for operating on the configuration from a given YAML file
7
+ # (or an input string in case of tests).
8
+ #
9
+
10
+
11
+ class Configuration(ConfigurationProjects):
12
+ pass
@@ -0,0 +1,19 @@
1
+ from abc import ABC
2
+
3
+ from gitlabform.configuration.core import ConfigurationCore
4
+
5
+
6
+ class ConfigurationCommon(ConfigurationCore, ABC):
7
+ """
8
+ Gets the common configuration, applied to all groups and projects.
9
+ """
10
+
11
+ def get_common_config(self) -> dict:
12
+ """
13
+ :return: literal common configuration or empty dict if not defined
14
+ """
15
+ common_config = self.get("projects_and_groups|*", {})
16
+ if common_config:
17
+ section_name = "*"
18
+ self._validate_break_inheritance_flag(common_config, section_name)
19
+ return common_config
@@ -0,0 +1,323 @@
1
+ import sys
2
+ from typing import Any
3
+
4
+ import os
5
+ import logging
6
+ import textwrap
7
+ from abc import ABC
8
+ from copy import deepcopy
9
+ from logging import debug
10
+ from pathlib import Path
11
+ from ruamel.yaml.scalarstring import ScalarString
12
+ from types import SimpleNamespace
13
+
14
+ from logging import critical, info
15
+
16
+ from mergedeep import merge
17
+ from yamlpath.common import Parsers
18
+ from yamlpath.wrappers import ConsolePrinter
19
+
20
+ from gitlabform.constants import EXIT_INVALID_INPUT
21
+ from ruamel.yaml.comments import CommentedMap
22
+
23
+
24
+ class ConfigurationCore(ABC):
25
+ """
26
+ The functionality shared by various types of config (common, groups, projects)
27
+ is implemented here.
28
+ """
29
+
30
+ def __init__(self, config_path=None, config_string=None):
31
+ if config_path and config_string:
32
+ critical("Please initialize with either config_path or config_string, not both.")
33
+ sys.exit(EXIT_INVALID_INPUT)
34
+ try:
35
+ if config_string:
36
+ self.config = self._parse_yaml(config_string, config_string=True)
37
+ self.config_dir = "."
38
+ else: # maybe config_path
39
+ config_path = self._get_config_path(config_path)
40
+ self.config = self._parse_yaml(config_path, config_string=False)
41
+ self.config_dir = os.path.dirname(config_path)
42
+
43
+ # below checks are only needed in the non-test mode, when the config is read from file
44
+
45
+ if self.config.get("example_config"):
46
+ critical(
47
+ "Example config detected, aborting.\n"
48
+ "Haven't you forgotten to use `-c <config_file>` parameter?\n"
49
+ "If you created your config based on the example config.yml,"
50
+ " then please remove 'example_config' key."
51
+ )
52
+ sys.exit(EXIT_INVALID_INPUT)
53
+
54
+ if self.config.get("config_version", 1) != 4:
55
+ critical(
56
+ "This version of GitLabForm requires 'config_version: 4' entry in the config. "
57
+ "This ensures that if the application behavior changes in a backward-incompatible way,"
58
+ " you won't apply unwanted configuration to your GitLab instance.\n"
59
+ "Please follow this guide: https://gitlabform.github.io/gitlabform/upgrade/\n"
60
+ )
61
+ sys.exit(EXIT_INVALID_INPUT)
62
+
63
+ self._find_almost_duplicates()
64
+
65
+ # we are NOT checking for the existence of non-empty 'projects_and_groups' key here
66
+ # as it would break using GitLabForm as a library
67
+
68
+ except (FileNotFoundError, OSError):
69
+ raise ConfigFileNotFoundException(config_path)
70
+
71
+ except Exception as e:
72
+ raise ConfigInvalidException(e)
73
+
74
+ @staticmethod
75
+ def _get_config_path(config_path):
76
+ if "APP_HOME" in os.environ:
77
+ # using this env var should be considered unofficial, we need this temporarily
78
+ # for backwards compatibility. support for it may be removed without notice, do not use it!
79
+ config_path = os.path.join(os.environ["APP_HOME"], "config.yml")
80
+ elif not config_path:
81
+ # this case is only meant for using gitlabform as a library
82
+ config_path = os.path.join(str(Path.home()), ".gitlabform", "config.yml")
83
+ elif config_path in [os.path.join(".", "config.yml"), "config.yml"]:
84
+ # provided points to config.yml in the app current working dir
85
+ config_path = os.path.join(os.getcwd(), "config.yml")
86
+
87
+ return config_path
88
+
89
+ @staticmethod
90
+ def _parse_yaml(source: str, config_string: bool):
91
+ logging_args = SimpleNamespace(quiet=False, verbose=False, debug=False)
92
+ log = ConsolePrinter(logging_args)
93
+
94
+ yaml = Parsers.get_yaml_editor()
95
+
96
+ # for better backward compatibility with PyYAML (that supports only YAML 1.1) used in the previous
97
+ # GitLabForm versions, let's force ruamel.yaml to use YAML version 1.1 by default too
98
+ yaml.version = (1, 1)
99
+
100
+ if config_string:
101
+ config = textwrap.dedent(source)
102
+ info("Reading config from the provided string.")
103
+ yaml_data, doc_loaded = Parsers.get_yaml_data(yaml, log, config, literal=True)
104
+ else:
105
+ config_path = source
106
+ info(f"Reading config from file: {config_path}")
107
+ yaml_data, doc_loaded = Parsers.get_yaml_data(yaml, log, config_path)
108
+
109
+ if doc_loaded:
110
+ debug("Config parsed successfully as YAML.")
111
+ else:
112
+ # an error message has already been printed via ConsolePrinter
113
+ exit(EXIT_INVALID_INPUT)
114
+
115
+ return yaml_data
116
+
117
+ def get(self, path, default=None) -> Any:
118
+ """
119
+ :param path: "path" to given element in YAML file, for example for:
120
+
121
+ group_settings:
122
+ sddc:
123
+ deploy_keys:
124
+ qa_puppet:
125
+ key: some key...
126
+ title: some title...
127
+ can_push: false
128
+
129
+ ..a path to a single element array ['qa_puppet'] will be: "group_settings|sddc|deploy_keys".
130
+
131
+ To get the dict under it use: get("group_settings|sddc|deploy_keys")
132
+
133
+ :param default: the value to return if the key is not found. The default 'None' means that an exception
134
+ will be raised in such case.
135
+ :return: element from YAML file (dict, array, string...)
136
+ """
137
+ tokens = path.split("|")
138
+ current = self.config
139
+
140
+ try:
141
+ for token in tokens:
142
+ current = current[token]
143
+
144
+ if isinstance(current, ScalarString):
145
+ to_return = str(current)
146
+ else:
147
+ to_return = current
148
+ except:
149
+ if default is not None:
150
+ to_return = default
151
+ else:
152
+ raise KeyNotFoundException(path) from None
153
+
154
+ return to_return
155
+
156
+ @staticmethod
157
+ def _validate_break_inheritance_flag(config: dict, section_name: str, parent_key: str = "") -> None:
158
+ for key, value in config.items():
159
+ if "inherit" == key:
160
+ parent_key_description = ' under key "' + parent_key + '"' if parent_key else ""
161
+ critical(
162
+ f'The inheritance-break flag set in "{section_name}"{parent_key_description} is invalid\n'
163
+ f"because it has no higher level setting to inherit from.\n"
164
+ )
165
+ sys.exit(EXIT_INVALID_INPUT)
166
+ elif type(value) in [CommentedMap, dict]:
167
+ ConfigurationCore._validate_break_inheritance_flag(value, section_name, key)
168
+
169
+ @staticmethod
170
+ def _merge_configs(more_general_config, more_specific_config) -> dict:
171
+ """
172
+ :return: merge more general config with more specific configs.
173
+ More specific config values take precedence over more general ones.
174
+ """
175
+
176
+ more_general_config = deepcopy(more_general_config)
177
+ more_specific_config = deepcopy(more_specific_config)
178
+
179
+ merged_dict = merge({}, more_general_config, more_specific_config)
180
+
181
+ def break_inheritance(specific_config, parent_path=()):
182
+ """
183
+ Walk the specific config tree and replace only the exact nested section
184
+ that declares ``inherit: false``.
185
+
186
+ ``parent_path`` stores the full key path to the current section because
187
+ the same section name, like ``variables``, can appear in multiple branches.
188
+ Replacing by section name alone can therefore update the wrong subtree.
189
+ """
190
+ for key, value in specific_config.items():
191
+ if "inherit" == key:
192
+ if not value:
193
+ replace_config_section(merged_dict, parent_path, specific_config)
194
+ break
195
+ elif value:
196
+ critical(f"Cannot set the inheritance break flag with true\n")
197
+ sys.exit(EXIT_INVALID_INPUT)
198
+ elif type(value) in [CommentedMap, dict]:
199
+ break_inheritance(value, parent_path + (key,))
200
+
201
+ def replace_config_section(merged_config, parent_path, specific_config):
202
+ """
203
+ Replace the merged section at ``parent_path`` with the specific section
204
+ that requested an inheritance break, dropping the control flag itself.
205
+ """
206
+ target_config = merged_config
207
+ for key in parent_path[:-1]:
208
+ target_config = target_config[key]
209
+
210
+ replacement_config = deepcopy(specific_config)
211
+ replacement_config.pop("inherit", None)
212
+ target_config[parent_path[-1]] = replacement_config
213
+
214
+ break_inheritance(more_specific_config)
215
+
216
+ return dict(merged_dict)
217
+
218
+ @staticmethod
219
+ def _get_case_insensitively(a_dict: dict, a_key: str):
220
+ for dict_key in a_dict.keys():
221
+ if dict_key.lower() == a_key.lower():
222
+ return a_dict[dict_key]
223
+ return {}
224
+
225
+ @staticmethod
226
+ def _is_skipped_case_insensitively(an_array: list, item: str) -> bool:
227
+ """
228
+ :return: if item is defined in the list to be skipped
229
+ """
230
+ item = item.lower()
231
+
232
+ for list_element in an_array:
233
+ list_element = list_element.lower()
234
+
235
+ if list_element == item:
236
+ return True
237
+
238
+ if (
239
+ list_element.endswith("/*")
240
+ and item.startswith(list_element[:-2])
241
+ and len(item) >= len(list_element[:-2])
242
+ ):
243
+ return True
244
+
245
+ return False
246
+
247
+ def _find_almost_duplicates(self):
248
+ # in GitLab groups and projects names are de facto case insensitive:
249
+ # you can change the case of both name and path BUT you cannot create
250
+ # 2 groups which names differ only with case and the same thing for
251
+ # projects. therefore we cannot allow such entries in the config,
252
+ # as they would be ambiguous.
253
+
254
+ for path in [
255
+ "projects_and_groups",
256
+ "skip_groups",
257
+ "skip_projects",
258
+ ]:
259
+ if self.get(path, 0):
260
+ almost_duplicates = self._find_almost_duplicates_in(path)
261
+ if almost_duplicates:
262
+ critical(
263
+ f"There are almost duplicates in the keys of {path} - they differ only in case.\n"
264
+ f"They are: {', '.join(almost_duplicates)}\n"
265
+ f"This is not allowed as we ignore the case for group and project names."
266
+ )
267
+ sys.exit(EXIT_INVALID_INPUT)
268
+
269
+ def _find_almost_duplicates_in(self, configuration_path):
270
+ """
271
+ Checks given configuration key and reads its keys - if it is a dict - or elements - if it is a list.
272
+ Looks for items that are almost the same - they differ only in the case.
273
+ :param configuration_path: configuration path, f.e. "group_settings"
274
+ :return: list of items that have almost duplicates,
275
+ or an empty list if none are found
276
+ """
277
+
278
+ dict_or_list = self.get(configuration_path)
279
+ if isinstance(dict_or_list, dict):
280
+ items = dict_or_list.keys()
281
+ else:
282
+ items = dict_or_list
283
+
284
+ items_with_lowercase_names = [x.lower() for x in items]
285
+
286
+ # casting these to sets will deduplicate the one with lowercase names
287
+ # lowering its cardinality if there were elements in it
288
+ # that before lowering differed only in case
289
+ if len(set(items)) != len(set(items_with_lowercase_names)):
290
+ # we have some almost duplicates, let's find them
291
+ almost_duplicates = []
292
+ for first_item in items:
293
+ occurrences = 0
294
+ for second_item in items_with_lowercase_names:
295
+ if first_item.lower() == second_item.lower():
296
+ occurrences += 1
297
+ if occurrences == 2:
298
+ almost_duplicates.append(first_item)
299
+ break
300
+ return almost_duplicates
301
+
302
+ else:
303
+ return []
304
+
305
+
306
+ class ConfigFileNotFoundException(Exception):
307
+ pass
308
+
309
+
310
+ class ConfigInvalidException(Exception):
311
+ def __init__(self, underlying: Exception):
312
+ self.underlying = underlying
313
+
314
+
315
+ class KeyNotFoundException(Exception):
316
+ __slots__ = "key"
317
+
318
+ def __init__(self, key: str):
319
+ if logging.getLogger().getEffectiveLevel() <= logging.DEBUG:
320
+ self.key = key
321
+ else:
322
+ critical(f"Unable to find the key: {key.replace('|', '.')}\n")
323
+ sys.exit(EXIT_INVALID_INPUT)
@@ -0,0 +1,127 @@
1
+ from abc import ABC
2
+
3
+ import functools
4
+ from logging import debug
5
+
6
+ from gitlabform.configuration import ConfigurationCommon
7
+ from gitlabform.util import to_str
8
+
9
+
10
+ class ConfigurationGroups(ConfigurationCommon, ABC):
11
+ """
12
+ Gets the groups, skipped groups and their effective configuration.
13
+
14
+ ConfigurationCommon is an ancestor of this class because the effective group configuration
15
+ depends on the common configuration.
16
+ """
17
+
18
+ def get_groups(self) -> list:
19
+ """
20
+ :return: sorted list of groups that are EXPLICITLY defined in the config
21
+ """
22
+ groups = []
23
+ projects_and_groups = self.get("projects_and_groups")
24
+ for element in projects_and_groups.keys():
25
+ if element.endswith("/*"):
26
+ # cut off that "/*"
27
+ group_name = element[:-2]
28
+ groups.append(group_name)
29
+ return sorted(groups)
30
+
31
+ def is_group_skipped(self, group):
32
+ """
33
+ :return: if group is defined in the key with groups to skip,
34
+ ignoring the case
35
+ """
36
+ return self._is_skipped_case_insensitively(self.get("skip_groups", []), group)
37
+
38
+ @functools.lru_cache()
39
+ def get_effective_config_for_group(self, group) -> dict:
40
+ """
41
+ :param group: "group_name"
42
+ :return: merged configuration for this group, from common, group. Merging is additive.
43
+ """
44
+ # Check if group must be skipped
45
+ if self.is_group_skipped(group):
46
+ debug(f"Group {group} is skipped, returning empty config")
47
+ return {}
48
+
49
+ common_config = self.get_common_config()
50
+ debug("*Effective* common config: %s", to_str(common_config))
51
+
52
+ if "/" in group:
53
+ group_config = self._get_effective_subgroup_config(group)
54
+ else:
55
+ group_config = self._get_group_config(group)
56
+ debug("*Effective* group/subgroup config: %s", to_str(group_config))
57
+
58
+ if not common_config and group_config:
59
+ self._validate_break_inheritance_flag(group_config, group)
60
+
61
+ effective_config_for_group = self._merge_configs(common_config, group_config)
62
+ debug(
63
+ "*Effective* config common+group/subgroup: %s",
64
+ to_str(effective_config_for_group),
65
+ )
66
+
67
+ return effective_config_for_group
68
+
69
+ def _get_effective_subgroup_config(self, subgroup):
70
+ #
71
+ # Goes through a subgroups hierarchy, from top to bottom
72
+ #
73
+ # "x/y/x" -> ["x", "x/y", "x/y/z"]
74
+ #
75
+ # ...and for each element after 1st generate effective config from previous effective one merged with current:
76
+ #
77
+ # | v |
78
+ # \---> a |
79
+ # | v
80
+ # \------>b = effective config to return
81
+ #
82
+ # ...where a = merged_config("x", "x/y") and b = merged_config(a, "x/y/z")
83
+ #
84
+
85
+ effective_config = {}
86
+ elements = subgroup.split("/")
87
+ last_element = None
88
+ for element in elements:
89
+ if not last_element:
90
+ effective_config = self._get_group_config(element)
91
+ debug("First level config for '%s': %s", element, to_str(effective_config))
92
+ last_element = element
93
+ else:
94
+ next_level_subgroup = last_element + "/" + element
95
+ next_level_subgroup_config = self._get_group_config(next_level_subgroup)
96
+ debug(
97
+ "Config for '%s': %s",
98
+ next_level_subgroup,
99
+ to_str(next_level_subgroup_config),
100
+ )
101
+
102
+ if effective_config:
103
+ self._validate_break_inheritance_flag(effective_config, subgroup)
104
+ elif not effective_config and next_level_subgroup_config:
105
+ self._validate_break_inheritance_flag(next_level_subgroup_config, next_level_subgroup)
106
+
107
+ effective_config = self._merge_configs(
108
+ effective_config,
109
+ next_level_subgroup_config,
110
+ )
111
+ debug(
112
+ "Merged previous level config for '%s' with config for '%s': %s",
113
+ last_element,
114
+ next_level_subgroup,
115
+ to_str(effective_config),
116
+ )
117
+ last_element = last_element + "/" + element
118
+
119
+ return effective_config
120
+
121
+ def _get_group_config(self, group) -> dict:
122
+ """
123
+ :param group: group/subgroup
124
+ :return: configuration for this group/subgroup or empty dict if not defined,
125
+ ignoring the case
126
+ """
127
+ return self._get_case_insensitively(self.get("projects_and_groups"), f"{group}/*")
@@ -0,0 +1,73 @@
1
+ from abc import ABC
2
+
3
+ import functools
4
+ from logging import debug
5
+
6
+ from gitlabform.configuration import ConfigurationGroups
7
+ from gitlabform.util import to_str
8
+
9
+
10
+ class ConfigurationProjects(ConfigurationGroups, ABC):
11
+ """
12
+ Gets the projects, skipped projects and their effective configuration.
13
+
14
+ ConfigurationGroups is an ancestor of this class because the effective project configuration
15
+ depends on the groups (and common) configuration.
16
+ """
17
+
18
+ def get_projects(self) -> list:
19
+ """
20
+ :return: sorted list of projects names, that are EXPLICITLY defined in the config
21
+ """
22
+ projects = []
23
+ projects_and_groups = self.get("projects_and_groups")
24
+ for element in projects_and_groups.keys():
25
+ if element != "*" and not element.endswith("/*"):
26
+ projects.append(element)
27
+ return sorted(projects)
28
+
29
+ def is_project_skipped(self, project) -> bool:
30
+ """
31
+ :return: if project is defined in the key with projects to skip,
32
+ ignoring the case
33
+ """
34
+ return self._is_skipped_case_insensitively(self.get("skip_projects", []), project)
35
+
36
+ @functools.lru_cache()
37
+ def get_effective_config_for_project(self, group_and_project) -> dict:
38
+ """
39
+ :param group_and_project: "project_group/project_name"
40
+ :return: merged configuration for this project, from common, group/subgroup and project level.
41
+ If project belongs to a subgroup, like "x/y/z", then it gets config from both group "x" as well
42
+ as subgroup "y".
43
+ Merging is additive.
44
+ """
45
+
46
+ group, _ = group_and_project.rsplit("/", 1)
47
+
48
+ effective_config_for_group = self.get_effective_config_for_group(group)
49
+
50
+ project_config = self._get_project_config(group_and_project)
51
+ debug("Project config: %s", to_str(project_config))
52
+
53
+ if not effective_config_for_group and project_config:
54
+ self._validate_break_inheritance_flag(project_config, group_and_project)
55
+
56
+ effective_config_for_project = self._merge_configs(
57
+ effective_config_for_group,
58
+ project_config,
59
+ )
60
+ debug(
61
+ "*Effective* config common+group/subgroup+project: %s",
62
+ to_str(effective_config_for_project),
63
+ )
64
+
65
+ return effective_config_for_project
66
+
67
+ def _get_project_config(self, group_and_project) -> dict:
68
+ """
69
+ :param group_and_project: 'group/project'
70
+ :return: configuration for this project or empty dict if not defined,
71
+ ignoring the case
72
+ """
73
+ return self._get_case_insensitively(self.get("projects_and_groups"), group_and_project)