saltext.github 1.1.0__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 (73) hide show
  1. saltext/github/__init__.py +31 -0
  2. saltext/github/modules/__init__.py +0 -0
  3. saltext/github/modules/github.py +1873 -0
  4. saltext/github/modules/github_org.py +667 -0
  5. saltext/github/modules/github_repo.py +1658 -0
  6. saltext/github/modules/github_user.py +75 -0
  7. saltext/github/resources/__init__.py +0 -0
  8. saltext/github/resources/github/__init__.py +251 -0
  9. saltext/github/resources/github/modules/__init__.py +0 -0
  10. saltext/github/resources/github/modules/actions.py +175 -0
  11. saltext/github/resources/github/modules/config.py +69 -0
  12. saltext/github/resources/github/modules/deploy_key.py +122 -0
  13. saltext/github/resources/github/modules/environment.py +241 -0
  14. saltext/github/resources/github/modules/grains.py +18 -0
  15. saltext/github/resources/github/modules/issue.py +157 -0
  16. saltext/github/resources/github/modules/label.py +161 -0
  17. saltext/github/resources/github/modules/milestone.py +103 -0
  18. saltext/github/resources/github/modules/pages.py +149 -0
  19. saltext/github/resources/github/modules/pull.py +90 -0
  20. saltext/github/resources/github/modules/repo.py +155 -0
  21. saltext/github/resources/github/modules/ruleset.py +144 -0
  22. saltext/github/resources/github/modules/saltutil.py +24 -0
  23. saltext/github/resources/github/modules/state.py +116 -0
  24. saltext/github/resources/github/modules/test.py +29 -0
  25. saltext/github/resources/github/states/__init__.py +0 -0
  26. saltext/github/resources/github/states/actions.py +117 -0
  27. saltext/github/resources/github/states/deploy_key.py +130 -0
  28. saltext/github/resources/github/states/environment.py +99 -0
  29. saltext/github/resources/github/states/label.py +71 -0
  30. saltext/github/resources/github/states/pages.py +92 -0
  31. saltext/github/resources/github/states/repo.py +108 -0
  32. saltext/github/resources/github/states/ruleset.py +81 -0
  33. saltext/github/states/__init__.py +0 -0
  34. saltext/github/states/github.py +966 -0
  35. saltext/github/states/github_org.py +369 -0
  36. saltext/github/states/github_repo.py +722 -0
  37. saltext/github/utils/__init__.py +72 -0
  38. saltext/github/utils/gh/__init__.py +14 -0
  39. saltext/github/utils/gh/actions.py +195 -0
  40. saltext/github/utils/gh/cache.py +126 -0
  41. saltext/github/utils/gh/client.py +534 -0
  42. saltext/github/utils/gh/deploy_keys.py +121 -0
  43. saltext/github/utils/gh/environments.py +271 -0
  44. saltext/github/utils/gh/exceptions.py +28 -0
  45. saltext/github/utils/gh/grains_files.py +366 -0
  46. saltext/github/utils/gh/issues.py +193 -0
  47. saltext/github/utils/gh/labels.py +142 -0
  48. saltext/github/utils/gh/members.py +157 -0
  49. saltext/github/utils/gh/milestones.py +122 -0
  50. saltext/github/utils/gh/pages.py +128 -0
  51. saltext/github/utils/gh/profile.py +458 -0
  52. saltext/github/utils/gh/pulls.py +99 -0
  53. saltext/github/utils/gh/repos.py +465 -0
  54. saltext/github/utils/gh/resource.py +35 -0
  55. saltext/github/utils/gh/rulesets.py +191 -0
  56. saltext/github/utils/gh/statecores/__init__.py +70 -0
  57. saltext/github/utils/gh/statecores/actions.py +180 -0
  58. saltext/github/utils/gh/statecores/deploy_keys.py +241 -0
  59. saltext/github/utils/gh/statecores/environments.py +212 -0
  60. saltext/github/utils/gh/statecores/labels.py +116 -0
  61. saltext/github/utils/gh/statecores/pages.py +177 -0
  62. saltext/github/utils/gh/statecores/repo.py +249 -0
  63. saltext/github/utils/gh/statecores/rulesets.py +115 -0
  64. saltext/github/utils/gh/teams.py +375 -0
  65. saltext/github/utils/gh/users.py +87 -0
  66. saltext/github/version.py +1 -0
  67. saltext_github-1.1.0.dist-info/METADATA +151 -0
  68. saltext_github-1.1.0.dist-info/RECORD +73 -0
  69. saltext_github-1.1.0.dist-info/WHEEL +5 -0
  70. saltext_github-1.1.0.dist-info/entry_points.txt +2 -0
  71. saltext_github-1.1.0.dist-info/licenses/LICENSE +201 -0
  72. saltext_github-1.1.0.dist-info/licenses/NOTICE +10 -0
  73. saltext_github-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,271 @@
1
+ """
2
+ Operations on GitHub repository (deployment) environments.
3
+ """
4
+
5
+ import logging
6
+ from urllib.parse import quote
7
+
8
+ from salt.exceptions import CommandExecutionError
9
+
10
+ from saltext.github.utils import filter_unset
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+
15
+ def _path(*suffix):
16
+ # Environment names can contain characters that are invalid in URL paths
17
+ return ["environments"] + [quote(str(segment), safe="") for segment in suffix]
18
+
19
+
20
+ def branch_policy_from_patterns(deployment_branches):
21
+ """
22
+ Map a ``deployment_branches`` specification (``all``, ``protected``
23
+ or a list of branch name patterns) onto the API's
24
+ ``deployment_branch_policy`` setting.
25
+
26
+ deployment_branches
27
+ Which branches can deploy. Either ``all``, ``protected``
28
+ (branches with branch protection rules) or a list of branch
29
+ name patterns.
30
+ """
31
+ if deployment_branches == "all":
32
+ return None
33
+ if deployment_branches == "protected":
34
+ return {"protected_branches": True, "custom_branch_policies": False}
35
+ if isinstance(deployment_branches, list):
36
+ return {"protected_branches": False, "custom_branch_policies": True}
37
+ raise CommandExecutionError(
38
+ "deployment_branches must be 'all', 'protected' or a list of branch "
39
+ f"name patterns, got: {deployment_branches!r}"
40
+ )
41
+
42
+
43
+ def normalize(env):
44
+ """
45
+ Extract the managed settings from an environment API payload into a
46
+ flat mapping with ``wait_timer``, ``prevent_self_review``, ``reviewers``
47
+ and ``deployment_branch_policy``. The API scatters them over
48
+ ``protection_rules`` entries.
49
+
50
+ env
51
+ Environment payload to normalize.
52
+ """
53
+ ret = {
54
+ "wait_timer": 0,
55
+ "prevent_self_review": False,
56
+ "reviewers": [],
57
+ "deployment_branch_policy": env.get("deployment_branch_policy"),
58
+ }
59
+ for rule in env.get("protection_rules") or []:
60
+ if rule.get("type") == "wait_timer":
61
+ ret["wait_timer"] = rule.get("wait_timer", 0)
62
+ elif rule.get("type") == "required_reviewers":
63
+ ret["prevent_self_review"] = rule.get("prevent_self_review", False)
64
+ ret["reviewers"] = [
65
+ {"type": reviewer["type"], "id": reviewer["reviewer"]["id"]}
66
+ for reviewer in rule.get("reviewers") or []
67
+ ]
68
+ return ret
69
+
70
+
71
+ def list_all(conn, repo_name, *, owner=None):
72
+ """
73
+ Return all environments of a repository.
74
+
75
+ conn
76
+ :class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
77
+
78
+ repo_name
79
+ Repository name.
80
+
81
+ owner
82
+ Owner of the repository. Defaults to the connection's owner.
83
+ """
84
+ return conn.repo_query(owner, repo_name, "environments", per_page=100)["environments"]
85
+
86
+
87
+ def get(conn, repo_name, name, *, owner=None):
88
+ """
89
+ Return the data of a single environment.
90
+
91
+ conn
92
+ :class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
93
+
94
+ repo_name
95
+ Repository name.
96
+
97
+ name
98
+ Environment name.
99
+
100
+ owner
101
+ Owner of the repository. Defaults to the connection's owner.
102
+
103
+ Raises :class:`~saltext.github.utils.gh.exceptions.GitHubNotFoundError`
104
+ if the environment does not exist.
105
+ """
106
+ return conn.repo_query(owner, repo_name, *_path(name))
107
+
108
+
109
+ def set_(
110
+ conn,
111
+ repo_name,
112
+ name,
113
+ *,
114
+ wait_timer=None,
115
+ prevent_self_review=None,
116
+ reviewers=None,
117
+ deployment_branch_policy=None,
118
+ owner=None,
119
+ ):
120
+ """
121
+ Create or update an environment, replacing its whole configuration.
122
+ Unspecified settings are reset to their defaults.
123
+ Returns the data of the environment.
124
+
125
+ conn
126
+ :class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
127
+
128
+ repo_name
129
+ Repository name.
130
+
131
+ name
132
+ Environment name.
133
+
134
+ wait_timer
135
+ Amount of time to delay a job after it is initially triggered,
136
+ in minutes.
137
+
138
+ prevent_self_review
139
+ Prevent a user who created a job from approving it.
140
+
141
+ reviewers
142
+ People or teams that may review jobs that reference the
143
+ environment, as a list of mappings with ``type`` (``User`` or
144
+ ``Team``) and ``id``.
145
+
146
+ deployment_branch_policy
147
+ Type of deployment branch policy for this environment, either
148
+ ``None`` (default) to allow all branches or a mapping with
149
+ ``protected_branches`` and ``custom_branch_policies`` booleans.
150
+ Branch name patterns for custom policies are managed separately,
151
+ see :func:`create_branch_policy`.
152
+
153
+ owner
154
+ Owner of the repository. Defaults to the connection's owner.
155
+ """
156
+ data = filter_unset(
157
+ {
158
+ "wait_timer": wait_timer,
159
+ "prevent_self_review": prevent_self_review,
160
+ "reviewers": reviewers,
161
+ }
162
+ )
163
+ data["deployment_branch_policy"] = deployment_branch_policy
164
+ return conn.repo_query(owner, repo_name, *_path(name), method="PUT", data=data)
165
+
166
+
167
+ def delete(conn, repo_name, name, *, owner=None):
168
+ """
169
+ Delete an environment from a repository.
170
+
171
+ conn
172
+ :class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
173
+
174
+ repo_name
175
+ Repository name.
176
+
177
+ name
178
+ Environment name.
179
+
180
+ owner
181
+ Owner of the repository. Defaults to the connection's owner.
182
+ """
183
+ conn.repo_query(owner, repo_name, *_path(name), method="DELETE")
184
+
185
+
186
+ def list_branch_policies(conn, repo_name, environment, *, owner=None):
187
+ """
188
+ Return all deployment branch policies of an environment.
189
+
190
+ conn
191
+ :class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
192
+
193
+ repo_name
194
+ Repository name.
195
+
196
+ environment
197
+ Environment name.
198
+
199
+ owner
200
+ Owner of the repository. Defaults to the connection's owner.
201
+ """
202
+ return conn.repo_query(
203
+ owner,
204
+ repo_name,
205
+ *_path(environment, "deployment-branch-policies"),
206
+ per_page=100,
207
+ )["branch_policies"]
208
+
209
+
210
+ def create_branch_policy(conn, repo_name, environment, name, *, typ="branch", owner=None):
211
+ """
212
+ Add a deployment branch (or tag) policy to an environment.
213
+ Returns the data of the created policy.
214
+
215
+ The environment's ``deployment_branch_policy`` must have
216
+ ``custom_branch_policies`` enabled.
217
+
218
+ conn
219
+ :class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
220
+
221
+ repo_name
222
+ Repository name.
223
+
224
+ environment
225
+ Environment name.
226
+
227
+ name
228
+ Name pattern that branches (or tags) must match in order to
229
+ deploy to the environment.
230
+
231
+ typ
232
+ Target of this rule, either ``branch`` or ``tag``.
233
+ Defaults to ``branch``.
234
+
235
+ owner
236
+ Owner of the repository. Defaults to the connection's owner.
237
+ """
238
+ return conn.repo_query(
239
+ owner,
240
+ repo_name,
241
+ *_path(environment, "deployment-branch-policies"),
242
+ method="POST",
243
+ data={"name": name, "type": typ},
244
+ )
245
+
246
+
247
+ def delete_branch_policy(conn, repo_name, environment, policy_id, *, owner=None):
248
+ """
249
+ Remove a deployment branch policy from an environment.
250
+
251
+ conn
252
+ :class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
253
+
254
+ repo_name
255
+ Repository name.
256
+
257
+ environment
258
+ Environment name.
259
+
260
+ policy_id
261
+ Branch policy ID.
262
+
263
+ owner
264
+ Owner of the repository. Defaults to the connection's owner.
265
+ """
266
+ conn.repo_query(
267
+ owner,
268
+ repo_name,
269
+ *_path(environment, "deployment-branch-policies", policy_id),
270
+ method="DELETE",
271
+ )
@@ -0,0 +1,28 @@
1
+ """
2
+ Exceptions raised by the GitHub API helper modules.
3
+ """
4
+
5
+ from salt.exceptions import CommandExecutionError
6
+
7
+
8
+ class GitHubApiError(CommandExecutionError):
9
+ """
10
+ The GitHub API returned an error response.
11
+
12
+ status
13
+ HTTP status code of the response, if available.
14
+
15
+ data
16
+ Decoded response body, if available.
17
+ """
18
+
19
+ def __init__(self, message, *, status=None, data=None):
20
+ super().__init__(message)
21
+ self.status = status
22
+ self.data = data
23
+
24
+
25
+ class GitHubNotFoundError(GitHubApiError):
26
+ """
27
+ A requested GitHub resource does not exist.
28
+ """
@@ -0,0 +1,366 @@
1
+ """
2
+ Custom repository grains sourced from in-repo YAML/JSON files, as
3
+ declared via the :ghconf:`resource:grains_files` key of the ``github``
4
+ resource type.
5
+
6
+ A declaration maps grain namespaces to file specifications::
7
+
8
+ grains_files:
9
+ copier:
10
+ path: .copier-answers.yml
11
+ exclude:
12
+ - "_*"
13
+ data:
14
+ path:
15
+ - .github/metadata.json
16
+ - .github/other-metadata.json
17
+ include:
18
+ - team
19
+ - org:config
20
+ - service_tier: service:tier
21
+
22
+ ``include``/``exclude`` entries are :py:mod:`fnmatch` patterns matched
23
+ case-sensitively against colon-delimited key paths, the same idiom Salt
24
+ uses for grain targeting. Note that ``*`` is not segment-aware (it can
25
+ cross ``:`` boundaries) and keys containing ``:`` cannot be addressed.
26
+ Mapping entries in ``include`` rename: the value at the exact source
27
+ path (scalar or subtree) is exposed under the new name as a flat
28
+ namespace key. Renames are not subject to ``exclude``. A plain mapping
29
+ as the ``include`` value is shorthand for a rename-only list.
30
+
31
+ The special ``_`` namespace merges its keys into the root grains level
32
+ instead of below a namespace key. Custom grains never overwrite
33
+ existing ones – the builtin identity grains and earlier namespaces
34
+ always win.
35
+ """
36
+
37
+ import copy
38
+ import fnmatch
39
+ import json
40
+ import logging
41
+
42
+ import salt.utils.data
43
+ import salt.utils.dictupdate
44
+ import salt.utils.yaml
45
+ import yaml
46
+ from salt.exceptions import CommandExecutionError
47
+
48
+ from saltext.github.utils.gh import repos as gh_repos
49
+ from saltext.github.utils.gh.exceptions import GitHubApiError
50
+
51
+ log = logging.getLogger(__name__)
52
+
53
+ #: Grain keys provided by the resource type itself. ``grains_files``
54
+ #: namespaces must not shadow them — the resource identity is not
55
+ #: overridable by repository contents.
56
+ RESERVED_KEYS = frozenset(
57
+ {
58
+ "id",
59
+ "owner",
60
+ "repo",
61
+ "full_name",
62
+ "private",
63
+ "visibility",
64
+ "fork",
65
+ "archived",
66
+ "disabled",
67
+ "is_template",
68
+ "default_branch",
69
+ "language",
70
+ "license",
71
+ "topics",
72
+ "custom_properties",
73
+ "html_url",
74
+ "clone_url",
75
+ "ssh_url",
76
+ "description",
77
+ "homepage",
78
+ "created_at",
79
+ "has_discussions",
80
+ "has_pages",
81
+ "has_projects",
82
+ }
83
+ )
84
+
85
+ _SPEC_KEYS = frozenset({"path", "include", "exclude"})
86
+
87
+ #: Sentinel for failed path traversal (``None`` is a valid value).
88
+ _MISSING = object()
89
+
90
+
91
+ def _invalid(namespace, msg):
92
+ return CommandExecutionError(f"Invalid grains_files namespace '{namespace}': {msg}")
93
+
94
+
95
+ def validate(spec):
96
+ """
97
+ Normalize and validate a ``grains_files`` declaration value.
98
+
99
+ Returns the normalized form, a mapping of namespace to
100
+ ``{"path": [str, ...], "include": [str | (new_name, source), ...] | None,
101
+ "exclude": [str, ...]}``. Raises a ``CommandExecutionError`` when the
102
+ declaration is malformed.
103
+
104
+ spec
105
+ The declaration value. ``None`` is treated as unset.
106
+ """
107
+ if spec is None:
108
+ return {}
109
+ if not isinstance(spec, dict):
110
+ raise CommandExecutionError(
111
+ "grains_files must be a mapping of namespace to file specification"
112
+ )
113
+ normalized = {}
114
+ for namespace, entry in spec.items():
115
+ if not isinstance(namespace, str) or not namespace:
116
+ raise CommandExecutionError(
117
+ f"grains_files namespaces must be non-empty strings, got: {namespace!r}"
118
+ )
119
+ if namespace in RESERVED_KEYS:
120
+ raise _invalid(namespace, "shadows a builtin resource grain")
121
+ if not isinstance(entry, dict):
122
+ raise _invalid(namespace, "the file specification must be a mapping")
123
+ if unknown := set(entry) - _SPEC_KEYS:
124
+ raise _invalid(namespace, f"unknown key(s): {', '.join(sorted(unknown))}")
125
+ paths = entry.get("path")
126
+ if isinstance(paths, str):
127
+ paths = [paths]
128
+ if (
129
+ not isinstance(paths, list)
130
+ or not paths
131
+ or not all(isinstance(path, str) and path for path in paths)
132
+ ):
133
+ raise _invalid(namespace, "'path' is required and must be a string or list of strings")
134
+ normalized[namespace] = {
135
+ "path": paths,
136
+ "include": _validate_include(namespace, entry.get("include")),
137
+ "exclude": _validate_exclude(namespace, entry.get("exclude")),
138
+ }
139
+ return normalized
140
+
141
+
142
+ def _validate_include(namespace, include):
143
+ if include is None:
144
+ return None
145
+ if isinstance(include, str):
146
+ include = [include]
147
+ elif isinstance(include, dict):
148
+ # Mapping shorthand for rename includes (unset values still imply regular inclusions)
149
+ include = [
150
+ {new_name: source} if source else new_name for new_name, source in include.items()
151
+ ]
152
+ elif not isinstance(include, list):
153
+ raise _invalid(namespace, "'include' must be a list, string or mapping")
154
+ res = []
155
+ for item in include:
156
+ if isinstance(item, str) and item:
157
+ res.append(item)
158
+ continue
159
+ if isinstance(item, dict) and len(item) == 1:
160
+ ((new_name, source),) = item.items()
161
+ if isinstance(new_name, str) and new_name and isinstance(source, str) and source:
162
+ res.append((new_name, source))
163
+ continue
164
+ raise _invalid(
165
+ namespace,
166
+ "'include' entries must be patterns or single-entry "
167
+ f"'new_name: source:path' renames, got: {item!r}",
168
+ )
169
+ return res
170
+
171
+
172
+ def _validate_exclude(namespace, exclude):
173
+ if exclude is None:
174
+ return []
175
+ if isinstance(exclude, str):
176
+ return [exclude]
177
+ if not isinstance(exclude, list) or not all(isinstance(pat, str) and pat for pat in exclude):
178
+ raise _invalid(namespace, "'exclude' must be a list of patterns")
179
+ return list(exclude)
180
+
181
+
182
+ def load_mapping(text, *, path):
183
+ """
184
+ Parse a file's text as YAML (which includes JSON) into a mapping of
185
+ JSON-plain values, or ``None`` (logging a warning) when the text does
186
+ not parse or does not represent a mapping.
187
+
188
+ text
189
+ The file content.
190
+
191
+ path
192
+ Identifier of the file's origin, used in log messages.
193
+ """
194
+ try:
195
+ parsed = salt.utils.yaml.safe_load(text)
196
+ except yaml.YAMLError as err:
197
+ log.warning("Failed parsing '%s' as YAML/JSON: %s", path, err)
198
+ return None
199
+ if not isinstance(parsed, dict):
200
+ log.warning("File '%s' does not contain a mapping, skipping", path)
201
+ return None
202
+ try:
203
+ # Normalize to JSON-plain types (e.g. YAML timestamps -> strings)
204
+ return json.loads(json.dumps(parsed, default=str))
205
+ except (TypeError, ValueError) as err:
206
+ log.warning("Failed normalizing the contents of '%s': %s", path, err)
207
+ return None
208
+
209
+
210
+ def filter_content(data, include=None, exclude=None):
211
+ """
212
+ Apply a normalized (per :func:`validate`) include/exclude
213
+ specification to a parsed file mapping and return the result.
214
+
215
+ data
216
+ The parsed file content.
217
+
218
+ include
219
+ Normalized include entries: fnmatch patterns against
220
+ colon-delimited key paths (a match includes the whole subtree,
221
+ preserving nesting) and ``(new_name, source)`` rename tuples
222
+ (exact source path, value exposed as flat ``new_name`` key).
223
+ ``None`` includes everything.
224
+
225
+ exclude
226
+ Fnmatch patterns pruning matching key paths at any depth after
227
+ includes. Does not apply to renames.
228
+ """
229
+ patterns = None
230
+ # need to make sure the first rename wins for consistency
231
+ renames = {}
232
+
233
+ if include is not None:
234
+ patterns = []
235
+ for item in include:
236
+ if isinstance(item, str):
237
+ patterns.append(item)
238
+ else:
239
+ new_name, source = item
240
+ if new_name not in renames:
241
+ renames[new_name] = source
242
+ result = _filter_node(data, "", patterns, exclude or [], patterns is None)
243
+ for new_name, source in renames.items():
244
+ source = source or new_name # unnormalized mapping-style include with no rename intention
245
+ value = salt.utils.data.traverse_dict_and_list(data, source, ..., delimiter=":")
246
+ if value is not ...:
247
+ salt.utils.dictupdate.set_dict_key_value(
248
+ result, new_name, copy.deepcopy(value), delimiter=":"
249
+ )
250
+ return result
251
+
252
+
253
+ def _filter_node(node, prefix, patterns, excludes, included_parent):
254
+ result = {}
255
+ for key, value in node.items():
256
+ path = f"{prefix}:{key}" if prefix else str(key)
257
+ if any(fnmatch.fnmatchcase(path, pat) for pat in excludes):
258
+ continue
259
+ included = included_parent or any(fnmatch.fnmatchcase(path, pat) for pat in patterns or ())
260
+ if isinstance(value, dict):
261
+ # Recurse into non-included subtrees as well since deeper
262
+ # paths can still match an include pattern.
263
+ sub = _filter_node(value, path, patterns, excludes, included)
264
+ if included or sub:
265
+ result[key] = sub
266
+ elif included:
267
+ result[key] = copy.deepcopy(value)
268
+ return result
269
+
270
+
271
+ def merge_grains(grains, custom):
272
+ """
273
+ Merge collected (per :func:`collect`) custom grains into a grains
274
+ mapping and return it. The special ``_`` namespace contributes its
275
+ keys at the root level, other namespaces become a single key each.
276
+ Keys already present – the builtin identity grains or earlier
277
+ namespaces – are never overwritten (logged and skipped).
278
+
279
+ grains
280
+ The grains mapping to merge into (mutated).
281
+
282
+ custom
283
+ Mapping of namespace to grain data.
284
+ """
285
+ for namespace, data in custom.items():
286
+ items = data.items() if namespace == "_" else ((namespace, data),)
287
+ for key, value in items:
288
+ ns_its = key.split(":")
289
+ ns_its.reverse()
290
+
291
+ tgt = grains
292
+ ns_it = ns_its.pop()
293
+ while ns_its:
294
+ if ns_it not in tgt:
295
+ tgt[ns_it] = {}
296
+ elif not isinstance(tgt[ns_it], dict):
297
+ log.warning("Custom grain '%s' shadows an existing grain, skipping", key)
298
+ break
299
+ tgt = tgt[ns_it]
300
+ ns_it = ns_its.pop()
301
+ else:
302
+ if ns_it not in tgt:
303
+ tgt[ns_it] = value
304
+ continue
305
+ if not isinstance(tgt[ns_it], dict) or not isinstance(value, dict):
306
+ log.warning("Custom grain '%s' shadows an existing grain, skipping", key)
307
+ continue
308
+ for k, v in value.items():
309
+ if k not in tgt[ns_it]:
310
+ tgt[ns_it][k] = v
311
+ continue
312
+ log.warning("Custom grain '%s:%s' shadows an existing grain, skipping", key, k)
313
+ return grains
314
+
315
+
316
+ def collect(conn, repo_name, spec, *, owner=None):
317
+ """
318
+ Fetch, parse, merge and filter all namespaces of a normalized (per
319
+ :func:`validate`) ``grains_files`` specification for one repository.
320
+
321
+ Returns a mapping of namespace to grain data; namespaces without any
322
+ resulting data are omitted. Per-file and per-namespace problems are
323
+ logged and skipped, never raised.
324
+
325
+ conn
326
+ :class:`~saltext.github.utils.gh.client.GitHubConnection` to use.
327
+
328
+ repo_name
329
+ Repository name.
330
+
331
+ spec
332
+ The normalized ``grains_files`` specification.
333
+
334
+ owner
335
+ Owner of the repository. Defaults to the connection's owner.
336
+ """
337
+ owner = owner or conn.owner
338
+ ret = {}
339
+ for namespace, entry in spec.items():
340
+ try:
341
+ merged = {}
342
+ for path in entry["path"]:
343
+ try:
344
+ content = gh_repos.get_file_content(conn, repo_name, path, owner=owner)
345
+ except GitHubApiError as err:
346
+ log.warning("Failed fetching '%s' from %s/%s: %s", path, owner, repo_name, err)
347
+ continue
348
+ if content is None:
349
+ continue
350
+ parsed = load_mapping(content, path=f"{owner}/{repo_name}:{path}")
351
+ if parsed is not None:
352
+ merged.update((k, v) for k, v in parsed.items() if k not in merged)
353
+ if not merged:
354
+ continue
355
+ data = filter_content(merged, include=entry["include"], exclude=entry["exclude"])
356
+ if data:
357
+ ret[namespace] = data
358
+ except Exception as err: # pylint: disable=broad-except
359
+ log.warning(
360
+ "Failed collecting custom grains namespace '%s' for %s/%s: %s",
361
+ namespace,
362
+ owner,
363
+ repo_name,
364
+ err,
365
+ )
366
+ return ret