check_build_test_rules.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. #!/usr/bin/env python
  2. # SPDX-FileCopyrightText: 2022-2023 Espressif Systems (Shanghai) CO LTD
  3. # SPDX-License-Identifier: Apache-2.0
  4. import argparse
  5. import inspect
  6. import os
  7. import re
  8. import sys
  9. from io import StringIO
  10. from pathlib import Path
  11. from typing import Dict, List, Optional, Tuple
  12. import yaml
  13. from idf_ci_utils import IDF_PATH, get_pytest_cases, get_ttfw_cases
  14. YES = u'\u2713'
  15. NO = u'\u2717'
  16. # | Supported Target | ... |
  17. # | ---------------- | --- |
  18. SUPPORTED_TARGETS_TABLE_REGEX = re.compile(
  19. r'^\|\s*Supported Targets.+$\n^\|(?:\s*|-).+$\n?', re.MULTILINE
  20. )
  21. USUAL_TO_FORMAL = {
  22. 'esp32': 'ESP32',
  23. 'esp32s2': 'ESP32-S2',
  24. 'esp32s3': 'ESP32-S3',
  25. 'esp32c3': 'ESP32-C3',
  26. 'esp32c2': 'ESP32-C2',
  27. 'esp32c6': 'ESP32-C6',
  28. 'esp32h2': 'ESP32-H2',
  29. 'esp32h4': 'ESP32-H4',
  30. 'linux': 'Linux',
  31. }
  32. FORMAL_TO_USUAL = {
  33. 'ESP32': 'esp32',
  34. 'ESP32-S2': 'esp32s2',
  35. 'ESP32-S3': 'esp32s3',
  36. 'ESP32-C3': 'esp32c3',
  37. 'ESP32-C2': 'esp32c2',
  38. 'ESP32-C6': 'esp32c6',
  39. 'ESP32-H2': 'esp32h2',
  40. 'ESP32-H4': 'esp32h4',
  41. 'Linux': 'linux',
  42. }
  43. def doublequote(s: str) -> str:
  44. if s.startswith('"') and s.endswith('"'):
  45. return s
  46. return f'"{s}"'
  47. def check_readme(
  48. paths: List[str],
  49. exclude_dirs: Optional[List[str]] = None,
  50. extra_default_build_targets: Optional[List[str]] = None,
  51. ) -> None:
  52. from idf_build_apps import App, find_apps
  53. from idf_build_apps.constants import SUPPORTED_TARGETS
  54. def get_readme_path(_app: App) -> Optional[str]:
  55. _readme_path = os.path.join(_app.app_dir, 'README.md')
  56. if not os.path.isfile(_readme_path):
  57. _readme_path = os.path.join(_app.app_dir, '..', 'README.md')
  58. if not os.path.isfile(_readme_path):
  59. _readme_path = None # type: ignore
  60. return _readme_path
  61. def _generate_new_support_table_str(_app: App) -> str:
  62. # extra space here
  63. table_headers = [
  64. f'{USUAL_TO_FORMAL[target]}' for target in _app.supported_targets
  65. ]
  66. table_headers = ['Supported Targets'] + table_headers
  67. res = '| ' + ' | '.join(table_headers) + ' |\n'
  68. res += '| ' + ' | '.join(['-' * len(item) for item in table_headers]) + ' |'
  69. return res
  70. def _parse_existing_support_table_str(_app: App) -> Tuple[Optional[str], List[str]]:
  71. _readme_path = get_readme_path(_app)
  72. if not _readme_path:
  73. return None, SUPPORTED_TARGETS
  74. with open(_readme_path) as _fr:
  75. _readme_str = _fr.read()
  76. support_string = SUPPORTED_TARGETS_TABLE_REGEX.findall(_readme_str)
  77. if not support_string:
  78. return None, SUPPORTED_TARGETS
  79. # old style
  80. parts = [
  81. part.strip()
  82. for part in support_string[0].split('\n', 1)[0].split('|')
  83. if part.strip()
  84. ]
  85. return support_string[0].strip(), [FORMAL_TO_USUAL[part] for part in parts[1:] if part in FORMAL_TO_USUAL]
  86. def check_enable_build(_app: App, _old_supported_targets: List[str]) -> bool:
  87. if _app.supported_targets == sorted(_old_supported_targets):
  88. return True
  89. _readme_path = get_readme_path(_app)
  90. if_clause = f'IDF_TARGET in [{", ".join([doublequote(target) for target in sorted(_old_supported_targets)])}]'
  91. print(
  92. inspect.cleandoc(
  93. f'''
  94. {_app.app_dir}:
  95. - enable build targets according to the manifest file: {_app.supported_targets}
  96. - enable build targets according to the old Supported Targets table under readme "{_readme_path}": {_old_supported_targets}
  97. If you want to disable some targets, please use the following snippet:
  98. # Please combine this with the original one
  99. #
  100. # Notes:
  101. # - please keep in mind to avoid duplicated folders as yaml keys
  102. # - please use parentheses to group conditions, the "or" and "and" operators could only accept two operands
  103. {_app.app_dir}:
  104. enable:
  105. - if: {if_clause}
  106. temporary: true
  107. reason: <why only enable build jobs for these targets>
  108. '''
  109. )
  110. )
  111. return False
  112. apps = sorted(
  113. find_apps(
  114. paths,
  115. 'all',
  116. recursive=True,
  117. exclude_list=exclude_dirs or [],
  118. manifest_files=[
  119. str(p) for p in Path(IDF_PATH).glob('**/.build-test-rules.yml')
  120. ],
  121. default_build_targets=SUPPORTED_TARGETS + extra_default_build_targets,
  122. )
  123. )
  124. exit_code = 0
  125. checked_app_dirs = set()
  126. for app in apps:
  127. if app.app_dir not in checked_app_dirs:
  128. checked_app_dirs.add(app.app_dir)
  129. else:
  130. continue
  131. replace_str, old_supported_targets = _parse_existing_support_table_str(app)
  132. success = check_enable_build(app, old_supported_targets)
  133. if not success:
  134. print(f'check_enable_build failed for app: {app}')
  135. print('-' * 80)
  136. exit_code = 1
  137. readme_path = get_readme_path(app)
  138. # no readme, create a new file
  139. if not readme_path:
  140. with open(os.path.join(app.app_dir, 'README.md'), 'w') as fw:
  141. fw.write(_generate_new_support_table_str(app) + '\n')
  142. print(f'Added new README file: {os.path.join(app.app_dir, "README.md")}')
  143. print('-' * 80)
  144. exit_code = 1
  145. # has old table, but different string
  146. elif replace_str and replace_str != _generate_new_support_table_str(app):
  147. with open(readme_path) as fr:
  148. readme_str = fr.read()
  149. with open(readme_path, 'w') as fw:
  150. fw.write(
  151. readme_str.replace(
  152. replace_str, _generate_new_support_table_str(app)
  153. )
  154. )
  155. print(f'Modified README file: {readme_path}')
  156. print('-' * 80)
  157. exit_code = 1
  158. # does not have old table
  159. elif not replace_str:
  160. with open(readme_path) as fr:
  161. readme_str = fr.read()
  162. with open(readme_path, 'w') as fw:
  163. fw.write(
  164. _generate_new_support_table_str(app) + '\n\n' + readme_str
  165. ) # extra new line
  166. print(f'Modified README file: {readme_path}')
  167. print('-' * 80)
  168. exit_code = 1
  169. sys.exit(exit_code)
  170. def check_test_scripts(
  171. paths: List[str],
  172. exclude_dirs: Optional[List[str]] = None,
  173. bypass_check_test_targets: Optional[List[str]] = None,
  174. extra_default_build_targets: Optional[List[str]] = None,
  175. ) -> None:
  176. from idf_build_apps import App, find_apps
  177. from idf_build_apps.constants import SUPPORTED_TARGETS
  178. # takes long time, run only in CI
  179. # dict:
  180. # {
  181. # app_dir: {
  182. # 'script_path': 'path/to/script',
  183. # 'targets': ['esp32', 'esp32s2', 'esp32s3', 'esp32c3', 'esp32c2', 'linux'],
  184. # }
  185. # }
  186. def check_enable_test(
  187. _app: App,
  188. _pytest_app_dir_targets_dict: Dict[str, Dict[str, str]],
  189. _ttfw_app_dir_targets_dict: Dict[str, Dict[str, str]],
  190. ) -> bool:
  191. if _app.app_dir in _pytest_app_dir_targets_dict:
  192. test_script_path = _pytest_app_dir_targets_dict[_app.app_dir]['script_path']
  193. actual_verified_targets = sorted(
  194. set(_pytest_app_dir_targets_dict[_app.app_dir]['targets'])
  195. )
  196. elif _app.app_dir in _ttfw_app_dir_targets_dict:
  197. test_script_path = _ttfw_app_dir_targets_dict[_app.app_dir]['script_path']
  198. actual_verified_targets = sorted(
  199. set(_ttfw_app_dir_targets_dict[_app.app_dir]['targets'])
  200. )
  201. else:
  202. return True # no test case
  203. if (
  204. _app.app_dir in _pytest_app_dir_targets_dict
  205. and _app.app_dir in _ttfw_app_dir_targets_dict
  206. ):
  207. print(
  208. f'''
  209. Both pytest and ttfw test cases are found for {_app.app_dir},
  210. please remove one of them.
  211. pytest script: {_pytest_app_dir_targets_dict[_app.app_dir]['script_path']}
  212. ttfw script: {_ttfw_app_dir_targets_dict[_app.app_dir]['script_path']}
  213. '''
  214. )
  215. return False
  216. actual_extra_tested_targets = set(actual_verified_targets) - set(
  217. _app.verified_targets
  218. )
  219. if actual_extra_tested_targets - set(bypass_check_test_targets or []):
  220. print(
  221. inspect.cleandoc(
  222. f'''
  223. {_app.app_dir}:
  224. - enable test targets according to the manifest file: {_app.verified_targets}
  225. - enable test targets according to the test scripts: {actual_verified_targets}
  226. test scripts enabled targets should be a subset of the manifest file declared ones.
  227. Please check the test script: {test_script_path}.
  228. '''
  229. )
  230. )
  231. return False
  232. if actual_verified_targets == _app.verified_targets:
  233. return True
  234. elif actual_verified_targets == sorted(_app.verified_targets + bypass_check_test_targets or []):
  235. print(f'WARNING: bypass test script check on {_app.app_dir} for targets {bypass_check_test_targets} ')
  236. return True
  237. if_clause = f'IDF_TARGET in [{", ".join([doublequote(target) for target in sorted(set(_app.verified_targets) - set(actual_verified_targets))])}]'
  238. print(
  239. inspect.cleandoc(
  240. f'''
  241. {_app.app_dir}:
  242. - enable test targets according to the manifest file: {_app.verified_targets}
  243. - enable test targets according to the test scripts: {actual_verified_targets}
  244. the test scripts enabled test targets should be the same with the manifest file enabled ones. Please check
  245. the test script manually: {test_script_path}.
  246. If you want to enable test targets in the pytest test scripts, please add `@pytest.mark.MISSING_TARGET`
  247. marker above the test case function.
  248. If you want to enable test targets in the ttfw test scripts, please add/extend the keyword `targets` in
  249. the ttfw decorator, e.g. `@ttfw_idf.idf_example_test(..., target=['esp32', 'MISSING_TARGET'])`
  250. If you want to disable the test targets in the manifest file, please modify your manifest file with
  251. the following code snippet:
  252. # Please combine this with the original one
  253. #
  254. # Notes:
  255. # - please keep in mind to avoid duplicated folders as yaml keys
  256. # - please use parentheses to group conditions, the "or" and "and" operators could only accept two operands
  257. {_app.app_dir}:
  258. disable_test:
  259. - if: {if_clause}
  260. temporary: true
  261. reason: <why you disable this test>
  262. '''
  263. )
  264. )
  265. return False
  266. apps = sorted(
  267. find_apps(
  268. paths,
  269. 'all',
  270. recursive=True,
  271. exclude_list=exclude_dirs or [],
  272. manifest_files=[
  273. str(p) for p in Path(IDF_PATH).glob('**/.build-test-rules.yml')
  274. ],
  275. default_build_targets=SUPPORTED_TARGETS + extra_default_build_targets,
  276. )
  277. )
  278. exit_code = 0
  279. pytest_cases = get_pytest_cases(paths)
  280. ttfw_cases = get_ttfw_cases(paths)
  281. pytest_app_dir_targets_dict = {}
  282. ttfw_app_dir_targets_dict = {}
  283. for case in pytest_cases:
  284. for pytest_app in case.apps:
  285. app_dir = os.path.relpath(pytest_app.path, IDF_PATH)
  286. if app_dir not in pytest_app_dir_targets_dict:
  287. pytest_app_dir_targets_dict[app_dir] = {
  288. 'script_path': case.path,
  289. 'targets': [pytest_app.target],
  290. }
  291. else:
  292. pytest_app_dir_targets_dict[app_dir]['targets'].append(
  293. pytest_app.target
  294. )
  295. for case in ttfw_cases:
  296. app_dir = case.case_info['app_dir']
  297. if app_dir not in ttfw_app_dir_targets_dict:
  298. ttfw_app_dir_targets_dict[app_dir] = {
  299. 'script_path': case.case_info['script_path'],
  300. 'targets': [case.case_info['target'].lower()],
  301. }
  302. else:
  303. ttfw_app_dir_targets_dict[app_dir]['targets'].append(
  304. case.case_info['target'].lower()
  305. )
  306. checked_app_dirs = set()
  307. for app in apps:
  308. if app.app_dir not in checked_app_dirs:
  309. checked_app_dirs.add(app.app_dir)
  310. else:
  311. continue
  312. success = check_enable_test(
  313. app, pytest_app_dir_targets_dict, ttfw_app_dir_targets_dict
  314. )
  315. if not success:
  316. print(f'check_enable_test failed for app: {app}')
  317. print('-' * 80)
  318. exit_code = 1
  319. continue
  320. sys.exit(exit_code)
  321. def sort_yaml(files: List[str]) -> None:
  322. from ruamel.yaml import YAML, CommentedMap
  323. yaml = YAML()
  324. yaml.indent(mapping=2, sequence=4, offset=2)
  325. yaml.width = 4096 # avoid wrap lines
  326. exit_code = 0
  327. for f in files:
  328. with open(f) as fr:
  329. file_s = fr.read()
  330. fr.seek(0)
  331. file_d: CommentedMap = yaml.load(fr)
  332. sorted_yaml = CommentedMap(dict(sorted(file_d.items())))
  333. file_d.copy_attributes(sorted_yaml)
  334. with StringIO() as s:
  335. yaml.dump(sorted_yaml, s)
  336. string = s.getvalue()
  337. if string != file_s:
  338. with open(f, 'w') as fw:
  339. fw.write(string)
  340. print(
  341. f'Sorted yaml file {f}. Please take a look. sometimes the format is a bit messy'
  342. )
  343. exit_code = 1
  344. sys.exit(exit_code)
  345. if __name__ == '__main__':
  346. if 'CI_JOB_ID' not in os.environ:
  347. os.environ['CI_JOB_ID'] = 'fake' # this is a CI script
  348. parser = argparse.ArgumentParser(description='ESP-IDF apps build/test checker')
  349. action = parser.add_subparsers(dest='action')
  350. _check_readme = action.add_parser('check-readmes')
  351. _check_readme.add_argument('paths', nargs='+', help='check under paths')
  352. _check_readme.add_argument(
  353. '-c',
  354. '--config',
  355. default=os.path.join(IDF_PATH, '.gitlab', 'ci', 'default-build-test-rules.yml'),
  356. help='default build test rules config file',
  357. )
  358. _check_test_scripts = action.add_parser('check-test-scripts')
  359. _check_test_scripts.add_argument('paths', nargs='+', help='check under paths')
  360. _check_test_scripts.add_argument(
  361. '-c',
  362. '--config',
  363. default=os.path.join(IDF_PATH, '.gitlab', 'ci', 'default-build-test-rules.yml'),
  364. help='default build test rules config file',
  365. )
  366. _sort_yaml = action.add_parser('sort-yaml')
  367. _sort_yaml.add_argument('files', nargs='+', help='all specified yaml files')
  368. arg = parser.parse_args()
  369. # Since this script is executed from the pre-commit hook environment, make sure IDF_PATH is set
  370. os.environ['IDF_PATH'] = os.path.realpath(
  371. os.path.join(os.path.dirname(__file__), '..', '..')
  372. )
  373. if arg.action == 'sort-yaml':
  374. sort_yaml(arg.files)
  375. else:
  376. check_dirs = set()
  377. # check if *_caps.h files changed
  378. check_all = False
  379. soc_caps_header_files = list(
  380. (Path(IDF_PATH) / 'components' / 'soc').glob('**/*_caps.h')
  381. )
  382. for p in arg.paths:
  383. if Path(p).resolve() in soc_caps_header_files:
  384. check_all = True
  385. break
  386. if os.path.isfile(p):
  387. check_dirs.add(os.path.dirname(p))
  388. else:
  389. check_dirs.add(p)
  390. if 'tools/idf_py_actions/constants.py' in arg.paths or 'tools/ci/check_build_test_rules.py' in arg.paths:
  391. check_all = True
  392. if check_all:
  393. check_dirs = {IDF_PATH}
  394. _exclude_dirs = [os.path.join(IDF_PATH, 'tools', 'unit-test-app'),
  395. os.path.join(IDF_PATH, 'tools', 'test_build_system', 'build_test_app')]
  396. else:
  397. _exclude_dirs = []
  398. extra_default_build_targets_list: List[str] = []
  399. bypass_check_test_targets_list: List[str] = []
  400. if arg.config:
  401. with open(arg.config) as fr:
  402. configs = yaml.safe_load(fr)
  403. if configs:
  404. extra_default_build_targets_list = (
  405. configs.get('extra_default_build_targets') or []
  406. )
  407. bypass_check_test_targets_list = (
  408. configs.get('bypass_check_test_targets') or []
  409. )
  410. if arg.action == 'check-readmes':
  411. check_readme(
  412. list(check_dirs),
  413. exclude_dirs=_exclude_dirs,
  414. extra_default_build_targets=extra_default_build_targets_list,
  415. )
  416. elif arg.action == 'check-test-scripts':
  417. check_test_scripts(
  418. list(check_dirs),
  419. exclude_dirs=_exclude_dirs,
  420. bypass_check_test_targets=bypass_check_test_targets_list,
  421. extra_default_build_targets=extra_default_build_targets_list,
  422. )