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

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: 27 ↛ 28line 27 didn't jump to line 28 because the condition on line 27 was never true

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