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

108 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-10 17:02 +0100

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 

18from __future__ import annotations 

19 

20import re 

21from typing import TYPE_CHECKING 

22 

23import yaml 

24 

25from gitlabracadabra.dictutils import update_dict_with_defaults 

26 

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 

29 

30 from gitlabracadabra.objects.object import GitLabracadabraObject 

31 

32 

33MAX_RECURSION = 10 

34 

35 

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