Coverage for src/gitlabracadabra/parser.py: 91%
108 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-14 23:10 +0200
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-14 23:10 +0200
1#
2# Copyright (C) 2013-2017 Gauvain Pocentek <gauvain@pocentek.net>
3# Copyright (C) 2019-2025 Mathieu Parent <math.parent@gmail.com>
4#
5# This program is free software: you can redistribute it and/or modify
6# it under the terms of the GNU Lesser General Public License as published by
7# the Free Software Foundation, either version 3 of the License, or
8# (at your option) any later version.
9#
10# This program is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13# GNU Lesser General Public License for more details.
14#
15# You should have received a copy of the GNU Lesser General Public License
16# along with this program. If not, see <http://www.gnu.org/licenses/>.
18from __future__ import annotations
20import re
21from typing import TYPE_CHECKING
23import yaml
25from gitlabracadabra.dictutils import update_dict_with_defaults
27if TYPE_CHECKING: 27 ↛ 28line 27 didn't jump to line 28 because the condition on line 27 was never true
28 from io import TextIOWrapper
30 from gitlabracadabra.objects.object import GitLabracadabraObject
33MAX_RECURSION = 10
36class GitlabracadabraParser:
37 """YAML parser."""
39 def __init__(self, action_file: str, config: dict, recursion: int = 0) -> None:
40 self._action_file = action_file
41 self._config = config
42 self._objects: dict[str, GitLabracadabraObject] | None = None
43 self._include = self._config.pop("include", [])
44 for included in self._include:
45 if recursion >= MAX_RECURSION:
46 msg = f"{self._action_file}: nesting too deep in `include`"
47 raise ValueError(msg)
48 if isinstance(included, str):
49 included = {"local": included}
50 if not isinstance(included, dict): 50 ↛ 51line 50 didn't jump to line 51 because the condition on line 50 was never true
51 msg = f"{self._action_file}: invalid value for `include`: {included}"
52 raise TypeError(msg)
53 if list(included.keys()) == ["local"] and isinstance(included["local"], str): 53 ↛ 59line 53 didn't jump to line 59 because the condition on line 53 was always true
54 if ".." in included["local"] or included["local"][0] == "/":
55 msg = "{}: forbidden path for `include`: {}".format(self._action_file, included["local"])
56 raise ValueError(msg)
57 included = self.from_yaml_file(included["local"], recursion + 1)
58 else:
59 msg = f"{self._action_file}: invalid value for `include`: {included}"
60 raise ValueError(msg)
61 update_dict_with_defaults(self._config, included._config) # noqa: SLF001
63 @classmethod
64 def from_yaml(
65 cls,
66 action_file: str,
67 yaml_blob: str | TextIOWrapper,
68 recursion: int = 0,
69 ) -> GitlabracadabraParser:
70 config = yaml.safe_load(yaml_blob)
71 return GitlabracadabraParser(action_file, config, recursion)
73 @classmethod
74 def from_yaml_file(cls, action_file: str, recursion: int = 0) -> GitlabracadabraParser:
75 with open(action_file) as yaml_blob:
76 return cls.from_yaml(action_file, yaml_blob, recursion)
78 """items()
80 Handle hidden objects (starting with a dot) and extends.
81 """
83 def _items(self):
84 for k, v in sorted(self._config.items()):
85 if k.startswith("."):
86 continue
87 recursion = 0
88 while "extends" in v:
89 recursion += 1
90 if recursion >= MAX_RECURSION:
91 msg = f"{self._action_file} ({k}): nesting too deep in `extends`"
92 raise ValueError(msg)
93 # No need to deepcopy as update_dict_with_defaults() does
94 v = v.copy()
95 extends = v.pop("extends")
96 if isinstance(extends, str):
97 extends = [extends]
98 for extends_item in reversed(extends):
99 if isinstance(extends_item, str):
100 extends_item = {extends_item: "deep"}
101 for extends_k, extends_v in extends_item.items():
102 try:
103 parent = self._config[extends_k]
104 except KeyError as exc:
105 msg = f"{self._action_file} (`{extends_k}` from `{k}`): {extends_k} not found"
106 raise ValueError(msg) from exc
107 if extends_v == "deep":
108 update_dict_with_defaults(v, parent)
109 elif extends_v == "replace":
110 result = parent.copy()
111 result.update(v)
112 v = result
113 elif extends_v == "aggregate":
114 update_dict_with_defaults(v, parent, aggregate=True)
115 else:
116 msg = (
117 f"{self._action_file} (`{extends_k}` from `{k}`): Unknown merge strategy `{extends_v}`"
118 )
119 raise ValueError(msg)
120 # Drop None values from v
121 yield (k, {a: b for a, b in v.items() if b is not None})
123 """_type_to_classname()
125 Converts object-type to GitLabracadabraObjectType.
126 """
128 @classmethod
129 def _type_to_classname(cls, obj_type: str) -> str:
130 splitted = re.split("[-_]", obj_type)
131 mapped = (s[0].upper() + s[1:].lower() for s in splitted)
132 return "GitLabracadabra" + "".join(mapped)
134 """_type_to_module()
136 Converts object-type to gitlabracadabra.objects.object_type.
137 """
139 @classmethod
140 def _type_to_module(cls, obj_type: str) -> str:
141 return "gitlabracadabra.objects." + obj_type.lower().replace("-", "_")
143 """get_class_for()
145 Get the class for the given object type.
146 """
148 @classmethod
149 def get_class_for(cls, obj_type: str) -> type[GitLabracadabraObject]:
150 obj_classname = cls._type_to_classname(obj_type)
151 obj_module = __import__(cls._type_to_module(obj_type), globals(), locals(), [obj_classname])
152 return getattr(obj_module, obj_classname) # type: ignore
154 """objects()
156 Returns .
157 """
159 def objects(self) -> dict[str, GitLabracadabraObject]:
160 if self._objects is not None: 160 ↛ 161line 160 didn't jump to line 161 because the condition on line 160 was never true
161 return self._objects
162 self._objects = {}
163 for k, v in self._items(): # type:ignore
164 if "type" in v: 164 ↛ 165line 164 didn't jump to line 165 because the condition on line 164 was never true
165 obj_type = v["type"]
166 v.pop("type")
167 elif k.endswith("/"):
168 obj_type = "group"
169 else:
170 obj_type = "project"
171 if k.endswith("/"):
172 k = k[:-1]
173 obj_class = self.get_class_for(obj_type)
174 self._objects[k] = obj_class(self._action_file, k, v)
175 return self._objects