Coverage for src/gitlabracadabra/parser.py: 92%

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/>.

17

18 from __future__ import annotations

19

20 import re

21 from typing import TYPE_CHECKING

22

23 import yaml

24

25 from gitlabracadabra.dictutils import update_dict_with_defaults

26

27 if TYPE_CHECKING:

28 from io import TextIOWrapper

29

30 from gitlabracadabra.objects.object import GitLabracadabraObject

31

32

33 MAX_RECURSION = 10

34

35

36 class GitlabracadabraParser:

37 """YAML parser."""

38

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

62

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)

72

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)

77

78 """items()

79

80 Handle hidden objects (starting with a dot) and extends.

81 """

82

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})

122

123 """_type_to_classname()

124

125 Converts object-type to GitLabracadabraObjectType.

126 """

127

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)

133

134 """_type_to_module()

135

136 Converts object-type to gitlabracadabra.objects.object_type.

137 """

138

139 @classmethod

140 def _type_to_module(cls, obj_type: str) -> str:

141 return "gitlabracadabra.objects." + obj_type.lower().replace("-", "_")

142

143 """get_class_for()

144

145 Get the class for the given object type.

146 """

147

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

153

154 """objects()

155

156 Returns .

157 """

158

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 k = k.removesuffix("/")

172 obj_class = self.get_class_for(obj_type)

173 self._objects[k] = obj_class(self._action_file, k, v)

174 return self._objects