generate_rules.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. #!/usr/bin/env python
  2. #
  3. # Copyright 2021 Espressif Systems (Shanghai) CO LTD
  4. #
  5. # Licensed under the Apache License, Version 2.0 (the "License");
  6. # you may not use this file except in compliance with the License.
  7. # You may obtain a copy of the License at
  8. #
  9. # http://www.apache.org/licenses/LICENSE-2.0
  10. #
  11. # Unless required by applicable law or agreed to in writing, software
  12. # distributed under the License is distributed on an "AS IS" BASIS,
  13. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  14. # See the License for the specific language governing permissions and
  15. # limitations under the License.
  16. import argparse
  17. import inspect
  18. import os
  19. import sys
  20. from collections import defaultdict
  21. from itertools import product
  22. import yaml
  23. try:
  24. import pygraphviz as pgv
  25. except ImportError: # used when pre-commit, skip generating image
  26. pass
  27. try:
  28. from typing import Union
  29. except ImportError: # used for type hint
  30. pass
  31. IDF_PATH = os.path.abspath(os.getenv('IDF_PATH', os.path.join(os.path.dirname(__file__), '..', '..', '..')))
  32. def _list(str_or_list): # type: (Union[str, list]) -> list
  33. if isinstance(str_or_list, str):
  34. return [str_or_list]
  35. elif isinstance(str_or_list, list):
  36. return str_or_list
  37. else:
  38. raise ValueError('Wrong type: {}. Only supports str or list.'.format(type(str_or_list)))
  39. def _format_nested_dict(_dict, f_tuple): # type: (dict[str, dict], tuple[str, ...]) -> dict[str, dict]
  40. res = {}
  41. for k, v in _dict.items():
  42. k = k.split('__')[0]
  43. if isinstance(v, dict):
  44. v = _format_nested_dict(v, f_tuple)
  45. elif isinstance(v, list):
  46. v = _format_nested_list(v, f_tuple)
  47. elif isinstance(v, str):
  48. v = v.format(*f_tuple)
  49. res[k.format(*f_tuple)] = v
  50. return res
  51. def _format_nested_list(_list, f_tuple): # type: (list[str], tuple[str, ...]) -> list[str]
  52. res = []
  53. for item in _list:
  54. if isinstance(item, list):
  55. item = _format_nested_list(item, f_tuple)
  56. elif isinstance(item, dict):
  57. item = _format_nested_dict(item, f_tuple)
  58. elif isinstance(item, str):
  59. item = item.format(*f_tuple)
  60. res.append(item)
  61. return res
  62. class RulesWriter:
  63. AUTO_GENERATE_MARKER = inspect.cleandoc(r'''
  64. ##################
  65. # Auto Generated #
  66. ##################
  67. ''')
  68. LABEL_TEMPLATE = inspect.cleandoc(r'''
  69. .if-label-{0}: &if-label-{0}
  70. if: '$BOT_LABEL_{1} || $CI_MERGE_REQUEST_LABELS =~ /^(?:[^,\n\r]+,)*{0}(?:,[^,\n\r]+)*$/i'
  71. ''')
  72. RULE_PROTECTED = ' - <<: *if-protected'
  73. RULE_PROTECTED_NO_LABEL = ' - <<: *if-protected-no_label'
  74. RULE_BUILD_ONLY = ' - <<: *if-label-build-only\n' \
  75. ' when: never'
  76. RULE_LABEL_TEMPLATE = ' - <<: *if-label-{0}'
  77. RULE_PATTERN_TEMPLATE = ' - <<: *if-dev-push\n' \
  78. ' changes: *patterns-{0}'
  79. RULES_TEMPLATE = inspect.cleandoc(r"""
  80. .rules:{0}:
  81. rules:
  82. {1}
  83. """)
  84. KEYWORDS = ['labels', 'patterns']
  85. def __init__(self, rules_yml, depend_yml): # type: (str, str) -> None
  86. self.rules_yml = rules_yml
  87. self.rules_cfg = yaml.load(open(rules_yml), Loader=yaml.FullLoader)
  88. self.full_cfg = yaml.load(open(depend_yml), Loader=yaml.FullLoader)
  89. self.cfg = {k: v for k, v in self.full_cfg.items() if not k.startswith('.')}
  90. self.cfg = self.expand_matrices()
  91. self.rules = self.expand_rules()
  92. self.graph = None
  93. def expand_matrices(self): # type: () -> dict
  94. """
  95. Expand the matrix into different rules
  96. """
  97. res = {}
  98. for k, v in self.cfg.items():
  99. res.update(self._expand_matrix(k, v))
  100. for k, v in self.cfg.items():
  101. if not v:
  102. continue
  103. deploy = v.get('deploy')
  104. if deploy:
  105. for item in _list(deploy):
  106. res['{}-{}'.format(k, item)] = v
  107. return res
  108. @staticmethod
  109. def _expand_matrix(name, cfg): # type: (str, dict) -> dict
  110. """
  111. Expand matrix into multi keys
  112. :param cfg: single rule dict
  113. :return:
  114. """
  115. default = {name: cfg}
  116. if not cfg:
  117. return default
  118. matrices = cfg.pop('matrix', None)
  119. if not matrices:
  120. return default
  121. res = {}
  122. for comb in product(*_list(matrices)):
  123. res.update(_format_nested_dict(default, comb))
  124. return res
  125. def expand_rules(self): # type: () -> dict[str, dict[str, list]]
  126. res = defaultdict(lambda: defaultdict(set)) # type: dict[str, dict[str, set]]
  127. for k, v in self.cfg.items():
  128. if not v:
  129. continue
  130. for vk, vv in v.items():
  131. if vk in self.KEYWORDS:
  132. res[k][vk] = set(_list(vv))
  133. else:
  134. res[k][vk] = vv
  135. for key in self.KEYWORDS: # provide empty set for missing field
  136. if key not in res[k]:
  137. res[k][key] = set()
  138. for k, v in self.cfg.items():
  139. if not v:
  140. continue
  141. if 'included_in' in v:
  142. for item in _list(v['included_in']):
  143. if 'labels' in v:
  144. res[item]['labels'].update(_list(v['labels']))
  145. if 'patterns' in v:
  146. for _pat in _list(v['patterns']):
  147. # Patterns must be pre-defined
  148. if '.patterns-{}'.format(_pat) not in self.rules_cfg:
  149. print('WARNING: pattern {} not exists'.format(_pat))
  150. continue
  151. res[item]['patterns'].add(_pat)
  152. sorted_res = defaultdict(lambda: defaultdict(list)) # type: dict[str, dict[str, list]]
  153. for k, v in res.items():
  154. for vk, vv in v.items():
  155. sorted_res[k][vk] = sorted(vv)
  156. return sorted_res
  157. def new_labels_str(self): # type: () -> str
  158. _labels = set([])
  159. for k, v in self.cfg.items():
  160. if not v:
  161. continue # shouldn't be possible
  162. labels = v.get('labels')
  163. if not labels:
  164. continue
  165. _labels.update(_list(labels))
  166. labels = sorted(_labels)
  167. res = ''
  168. res += '\n\n'.join([self._format_label(_label) for _label in labels])
  169. return res
  170. @classmethod
  171. def _format_label(cls, label): # type: (str) -> str
  172. return cls.LABEL_TEMPLATE.format(label, cls.bot_label_str(label))
  173. @staticmethod
  174. def bot_label_str(label): # type: (str) -> str
  175. return label.upper().replace('-', '_')
  176. def new_rules_str(self): # type: () -> str
  177. res = []
  178. for k, v in sorted(self.rules.items()):
  179. res.append(self.RULES_TEMPLATE.format(k, self._format_rule(k, v)))
  180. return '\n\n'.join(res)
  181. def _format_rule(self, name, cfg): # type: (str, dict) -> str
  182. _rules = []
  183. if name.endswith('-production'):
  184. _rules.append(self.RULE_PROTECTED_NO_LABEL)
  185. else:
  186. if not (name.endswith('-preview') or name.startswith('labels:')):
  187. _rules.append(self.RULE_PROTECTED)
  188. # Special case for esp32c3 example_test, for now it only run with label
  189. if name.startswith('test:') or name == 'labels:example_test-esp32c3':
  190. _rules.append(self.RULE_BUILD_ONLY)
  191. for label in cfg['labels']:
  192. _rules.append(self.RULE_LABEL_TEMPLATE.format(label))
  193. for pattern in cfg['patterns']:
  194. if '.patterns-{}'.format(pattern) in self.rules_cfg:
  195. _rules.append(self.RULE_PATTERN_TEMPLATE.format(pattern))
  196. else:
  197. print('WARNING: pattern {} not exists'.format(pattern))
  198. return '\n'.join(_rules)
  199. def update_rules_yml(self): # type: () -> bool
  200. with open(self.rules_yml) as fr:
  201. file_str = fr.read()
  202. auto_generate_str = '\n{}\n\n{}\n'.format(self.new_labels_str(), self.new_rules_str())
  203. rest, marker, old = file_str.partition(self.AUTO_GENERATE_MARKER)
  204. if old == auto_generate_str:
  205. return False
  206. else:
  207. print(self.rules_yml, 'has been modified. Please check')
  208. with open(self.rules_yml, 'w') as fw:
  209. fw.write(rest + marker + auto_generate_str)
  210. return True
  211. LABEL_COLOR = 'green'
  212. PATTERN_COLOR = 'cyan'
  213. RULE_COLOR = 'blue'
  214. def build_graph(rules_dict): # type: (dict[str, dict[str, list]]) -> pgv.AGraph
  215. graph = pgv.AGraph(directed=True, rankdir='LR', concentrate=True)
  216. for k, v in rules_dict.items():
  217. if not v:
  218. continue
  219. included_in = v.get('included_in')
  220. if included_in:
  221. for item in _list(included_in):
  222. graph.add_node(k, color=RULE_COLOR)
  223. graph.add_node(item, color=RULE_COLOR)
  224. graph.add_edge(k, item, color=RULE_COLOR)
  225. labels = v.get('labels')
  226. if labels:
  227. for _label in labels:
  228. graph.add_node('label:{}'.format(_label), color=LABEL_COLOR)
  229. graph.add_edge('label:{}'.format(_label), k, color=LABEL_COLOR)
  230. patterns = v.get('patterns')
  231. if patterns:
  232. for _pat in patterns:
  233. graph.add_node('pattern:{}'.format(_pat), color=PATTERN_COLOR)
  234. graph.add_edge('pattern:{}'.format(_pat), k, color=PATTERN_COLOR)
  235. return graph
  236. def output_graph(graph, output_path='output.png'): # type: (pgv.AGraph, str) -> None
  237. graph.layout('dot')
  238. if output_path.endswith('.png'):
  239. img_path = output_path
  240. else:
  241. img_path = os.path.join(output_path, 'output.png')
  242. graph.draw(img_path)
  243. if __name__ == '__main__':
  244. parser = argparse.ArgumentParser(description=__doc__)
  245. parser.add_argument('rules_yml', nargs='?', default=os.path.join(IDF_PATH, '.gitlab', 'ci', 'rules.yml'),
  246. help='rules.yml file path')
  247. parser.add_argument('dependencies_yml', nargs='?', default=os.path.join(IDF_PATH, '.gitlab', 'ci', 'dependencies',
  248. 'dependencies.yml'),
  249. help='dependencies.yml file path')
  250. parser.add_argument('--graph',
  251. help='Specify PNG image output path. Use this argument to generate dependency graph')
  252. args = parser.parse_args()
  253. writer = RulesWriter(args.rules_yml, args.dependencies_yml)
  254. file_modified = writer.update_rules_yml()
  255. if args.graph:
  256. dep_tree_graph = build_graph(writer.rules)
  257. output_graph(dep_tree_graph)
  258. sys.exit(file_modified)