common.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. # coding=utf-8
  2. import fnmatch
  3. import json
  4. import logging
  5. import os
  6. import re
  7. import shutil
  8. import subprocess
  9. import sys
  10. import typing
  11. from abc import abstractmethod
  12. from collections import namedtuple
  13. from io import open
  14. DEFAULT_TARGET = 'esp32'
  15. TARGET_PLACEHOLDER = '@t'
  16. WILDCARD_PLACEHOLDER = '@w'
  17. NAME_PLACEHOLDER = '@n'
  18. FULL_NAME_PLACEHOLDER = '@f'
  19. INDEX_PLACEHOLDER = '@i'
  20. IDF_SIZE_PY = os.path.join(os.environ['IDF_PATH'], 'tools', 'idf_size.py')
  21. SIZE_JSON_FN = 'size.json'
  22. SDKCONFIG_LINE_REGEX = re.compile(r"^([^=]+)=\"?([^\"\n]*)\"?\n*$")
  23. # If these keys are present in sdkconfig.defaults, they will be extracted and passed to CMake
  24. SDKCONFIG_TEST_OPTS = [
  25. 'EXCLUDE_COMPONENTS',
  26. 'TEST_EXCLUDE_COMPONENTS',
  27. 'TEST_COMPONENTS',
  28. ]
  29. # These keys in sdkconfig.defaults are not propagated to the final sdkconfig file:
  30. SDKCONFIG_IGNORE_OPTS = [
  31. 'TEST_GROUPS'
  32. ]
  33. # ConfigRule represents one --config argument of find_apps.py.
  34. # file_name is the name of the sdkconfig file fragment, optionally with a single wildcard ('*' character).
  35. # file_name can also be empty to indicate that the default configuration of the app should be used.
  36. # config_name is the name of the corresponding build configuration, or None if the value of wildcard is to be used.
  37. # For example:
  38. # filename='', config_name='default' — represents the default app configuration, and gives it a name 'default'
  39. # filename='sdkconfig.*', config_name=None - represents the set of configurations, names match the wildcard value
  40. ConfigRule = namedtuple('ConfigRule', ['file_name', 'config_name'])
  41. def config_rules_from_str(rule_strings): # type: (typing.List[str]) -> typing.List[ConfigRule]
  42. """
  43. Helper function to convert strings like 'file_name=config_name' into ConfigRule objects
  44. :param rule_strings: list of rules as strings
  45. :return: list of ConfigRules
  46. """
  47. rules = [] # type: typing.List[ConfigRule]
  48. for rule_str in rule_strings:
  49. items = rule_str.split('=', 2)
  50. rules.append(ConfigRule(items[0], items[1] if len(items) == 2 else None))
  51. return rules
  52. def find_first_match(pattern, path):
  53. for root, _, files in os.walk(path):
  54. res = fnmatch.filter(files, pattern)
  55. if res:
  56. return os.path.join(root, res[0])
  57. return None
  58. def rmdir(path, exclude_file_pattern=None):
  59. if not exclude_file_pattern:
  60. shutil.rmtree(path, ignore_errors=True)
  61. return
  62. for root, dirs, files in os.walk(path, topdown=False):
  63. for f in files:
  64. if not fnmatch.fnmatch(f, exclude_file_pattern):
  65. os.remove(os.path.join(root, f))
  66. for d in dirs:
  67. try:
  68. os.rmdir(os.path.join(root, d))
  69. except OSError:
  70. pass
  71. class BuildItem(object):
  72. """
  73. Instance of this class represents one build of an application.
  74. The parameters which distinguish the build are passed to the constructor.
  75. """
  76. def __init__(
  77. self,
  78. app_path,
  79. work_dir,
  80. build_path,
  81. build_log_path,
  82. target,
  83. sdkconfig_path,
  84. config_name,
  85. build_system,
  86. preserve_artifacts,
  87. ):
  88. # These internal variables store the paths with environment variables and placeholders;
  89. # Public properties with similar names use the _expand method to get the actual paths.
  90. self._app_dir = app_path
  91. self._work_dir = work_dir
  92. self._build_dir = build_path
  93. self._build_log_path = build_log_path
  94. self.sdkconfig_path = sdkconfig_path
  95. self.config_name = config_name
  96. self.target = target
  97. self.build_system = build_system
  98. self.preserve = preserve_artifacts
  99. self._app_name = os.path.basename(os.path.normpath(app_path))
  100. self.size_json_fp = None
  101. # Some miscellaneous build properties which are set later, at the build stage
  102. self.index = None
  103. self.verbose = False
  104. self.dry_run = False
  105. self.keep_going = False
  106. self.work_path = self.work_dir or self.app_dir
  107. if not self.build_dir:
  108. self.build_path = os.path.join(self.work_path, 'build')
  109. elif os.path.isabs(self.build_dir):
  110. self.build_path = self.build_dir
  111. else:
  112. self.build_path = os.path.normpath(os.path.join(self.work_path, self.build_dir))
  113. @property
  114. def app_dir(self):
  115. """
  116. :return: directory of the app
  117. """
  118. return self._expand(self._app_dir)
  119. @property
  120. def work_dir(self):
  121. """
  122. :return: directory where the app should be copied to, prior to the build. Can be None, which means that the app
  123. directory should be used.
  124. """
  125. return self._expand(self._work_dir)
  126. @property
  127. def build_dir(self):
  128. """
  129. :return: build directory, either relative to the work directory (if relative path is used) or absolute path.
  130. """
  131. return self._expand(self._build_dir)
  132. @property
  133. def build_log_path(self):
  134. """
  135. :return: path of the build log file
  136. """
  137. return self._expand(self._build_log_path)
  138. def __repr__(self):
  139. return '({}) Build app {} for target {}, sdkconfig {} in {}'.format(
  140. self.build_system,
  141. self.app_dir,
  142. self.target,
  143. self.sdkconfig_path or '(default)',
  144. self.build_dir,
  145. )
  146. def to_json(self): # type: () -> str
  147. """
  148. :return: JSON string representing this object
  149. """
  150. return self._to_json(self._app_dir, self._work_dir, self._build_dir, self._build_log_path)
  151. def to_json_expanded(self): # type: () -> str
  152. """
  153. :return: JSON string representing this object, with all placeholders in paths expanded
  154. """
  155. return self._to_json(self.app_dir, self.work_dir, self.build_dir, self.build_log_path)
  156. def _to_json(self, app_dir, work_dir, build_dir, build_log_path): # type: (str, str, str, str) -> str
  157. """
  158. Internal function, called by to_json and to_json_expanded
  159. """
  160. return json.dumps({
  161. 'build_system': self.build_system,
  162. 'app_dir': app_dir,
  163. 'work_dir': work_dir,
  164. 'build_dir': build_dir,
  165. 'build_log_path': build_log_path,
  166. 'sdkconfig': self.sdkconfig_path,
  167. 'config': self.config_name,
  168. 'target': self.target,
  169. 'verbose': self.verbose,
  170. 'preserve': self.preserve,
  171. })
  172. @staticmethod
  173. def from_json(json_str): # type: (typing.Text) -> BuildItem
  174. """
  175. :return: Get the BuildItem from a JSON string
  176. """
  177. d = json.loads(str(json_str))
  178. result = BuildItem(
  179. app_path=d['app_dir'],
  180. work_dir=d['work_dir'],
  181. build_path=d['build_dir'],
  182. build_log_path=d['build_log_path'],
  183. sdkconfig_path=d['sdkconfig'],
  184. config_name=d['config'],
  185. target=d['target'],
  186. build_system=d['build_system'],
  187. preserve_artifacts=d['preserve']
  188. )
  189. result.verbose = d['verbose']
  190. return result
  191. def _expand(self, path): # type: (str) -> str
  192. """
  193. Internal method, expands any of the placeholders in {app,work,build} paths.
  194. """
  195. if not path:
  196. return path
  197. if self.index is not None:
  198. path = path.replace(INDEX_PLACEHOLDER, str(self.index))
  199. path = path.replace(TARGET_PLACEHOLDER, self.target)
  200. path = path.replace(NAME_PLACEHOLDER, self._app_name)
  201. if (FULL_NAME_PLACEHOLDER in path): # to avoid recursion to the call to app_dir in the next line:
  202. path = path.replace(FULL_NAME_PLACEHOLDER, self.app_dir.replace(os.path.sep, '_'))
  203. wildcard_pos = path.find(WILDCARD_PLACEHOLDER)
  204. if wildcard_pos != -1:
  205. if self.config_name:
  206. # if config name is defined, put it in place of the placeholder
  207. path = path.replace(WILDCARD_PLACEHOLDER, self.config_name)
  208. else:
  209. # otherwise, remove the placeholder and one character on the left
  210. # (which is usually an underscore, dash, or other delimiter)
  211. left_of_wildcard = max(0, wildcard_pos - 1)
  212. right_of_wildcard = wildcard_pos + len(WILDCARD_PLACEHOLDER)
  213. path = path[0:left_of_wildcard] + path[right_of_wildcard:]
  214. path = os.path.expandvars(path)
  215. return path
  216. def get_size_json_fp(self):
  217. if self.size_json_fp and os.path.exists(self.size_json_fp):
  218. return self.size_json_fp
  219. assert os.path.exists(self.build_path)
  220. assert os.path.exists(self.work_path)
  221. map_file = find_first_match('*.map', self.build_path)
  222. if not map_file:
  223. raise ValueError('.map file not found under "{}"'.format(self.build_path))
  224. size_json_fp = os.path.join(self.build_path, SIZE_JSON_FN)
  225. idf_size_args = [
  226. sys.executable,
  227. IDF_SIZE_PY,
  228. '--json',
  229. '-o', size_json_fp,
  230. map_file
  231. ]
  232. subprocess.check_call(idf_size_args)
  233. return size_json_fp
  234. def write_size_info(self, size_info_fs):
  235. if not self.size_json_fp or (not os.path.exists(self.size_json_fp)):
  236. raise OSError('Run get_size_json_fp() for app {} after built binary'.format(self.app_dir))
  237. size_info_dict = {
  238. 'app_name': self._app_name,
  239. 'config_name': self.config_name,
  240. 'target': self.target,
  241. 'path': self.size_json_fp,
  242. }
  243. size_info_fs.write(json.dumps(size_info_dict) + '\n')
  244. class BuildSystem:
  245. """
  246. Class representing a build system.
  247. Derived classes implement the methods below.
  248. Objects of these classes aren't instantiated, instead the class (type object) is used.
  249. """
  250. NAME = 'undefined'
  251. SUPPORTED_TARGETS_REGEX = re.compile(r'Supported [Tt]argets((?:[ |]+(?:[0-9a-zA-Z\-]+))+)')
  252. FORMAL_TO_USUAL = {
  253. 'ESP32': 'esp32',
  254. 'ESP32-S2': 'esp32s2',
  255. 'ESP32-S3': 'esp32s3',
  256. 'ESP32-C3': 'esp32c3',
  257. 'ESP32-H2': 'esp32h2',
  258. 'Linux': 'linux',
  259. }
  260. @classmethod
  261. def build_prepare(cls, build_item):
  262. app_path = build_item.app_dir
  263. work_path = build_item.work_path
  264. build_path = build_item.build_path
  265. if work_path != app_path:
  266. if os.path.exists(work_path):
  267. logging.debug('Work directory {} exists, removing'.format(work_path))
  268. if not build_item.dry_run:
  269. shutil.rmtree(work_path)
  270. logging.debug('Copying app from {} to {}'.format(app_path, work_path))
  271. if not build_item.dry_run:
  272. shutil.copytree(app_path, work_path)
  273. if os.path.exists(build_path):
  274. logging.debug('Build directory {} exists, removing'.format(build_path))
  275. if not build_item.dry_run:
  276. shutil.rmtree(build_path)
  277. if not build_item.dry_run:
  278. os.makedirs(build_path)
  279. # Prepare the sdkconfig file, from the contents of sdkconfig.defaults (if exists) and the contents of
  280. # build_info.sdkconfig_path, i.e. the config-specific sdkconfig file.
  281. #
  282. # Note: the build system supports taking multiple sdkconfig.defaults files via SDKCONFIG_DEFAULTS
  283. # CMake variable. However here we do this manually to perform environment variable expansion in the
  284. # sdkconfig files.
  285. sdkconfig_defaults_list = ['sdkconfig.defaults', 'sdkconfig.defaults.' + build_item.target]
  286. if build_item.sdkconfig_path:
  287. sdkconfig_defaults_list.append(build_item.sdkconfig_path)
  288. sdkconfig_file = os.path.join(work_path, 'sdkconfig')
  289. if os.path.exists(sdkconfig_file):
  290. logging.debug('Removing sdkconfig file: {}'.format(sdkconfig_file))
  291. if not build_item.dry_run:
  292. os.unlink(sdkconfig_file)
  293. logging.debug('Creating sdkconfig file: {}'.format(sdkconfig_file))
  294. extra_cmakecache_items = {}
  295. if not build_item.dry_run:
  296. with open(sdkconfig_file, 'w') as f_out:
  297. for sdkconfig_name in sdkconfig_defaults_list:
  298. sdkconfig_path = os.path.join(work_path, sdkconfig_name)
  299. if not sdkconfig_path or not os.path.exists(sdkconfig_path):
  300. continue
  301. logging.debug('Appending {} to sdkconfig'.format(sdkconfig_name))
  302. with open(sdkconfig_path, 'r') as f_in:
  303. for line in f_in:
  304. if not line.endswith('\n'):
  305. line += '\n'
  306. if cls.NAME == 'cmake':
  307. m = SDKCONFIG_LINE_REGEX.match(line)
  308. key = m.group(1) if m else None
  309. if key in SDKCONFIG_TEST_OPTS:
  310. extra_cmakecache_items[key] = m.group(2)
  311. continue
  312. if key in SDKCONFIG_IGNORE_OPTS:
  313. continue
  314. f_out.write(os.path.expandvars(line))
  315. else:
  316. for sdkconfig_name in sdkconfig_defaults_list:
  317. sdkconfig_path = os.path.join(app_path, sdkconfig_name)
  318. if not sdkconfig_path:
  319. continue
  320. logging.debug('Considering sdkconfig {}'.format(sdkconfig_path))
  321. if not os.path.exists(sdkconfig_path):
  322. continue
  323. logging.debug('Appending {} to sdkconfig'.format(sdkconfig_name))
  324. # The preparation of build is finished. Implement the build part in sub classes.
  325. if cls.NAME == 'cmake':
  326. return build_path, work_path, extra_cmakecache_items
  327. else:
  328. return build_path, work_path
  329. @staticmethod
  330. @abstractmethod
  331. def build(build_item):
  332. pass
  333. @staticmethod
  334. @abstractmethod
  335. def is_app(path):
  336. pass
  337. @staticmethod
  338. def _read_readme(app_path):
  339. # Markdown supported targets should be:
  340. # e.g. | Supported Targets | ESP32 |
  341. # | ----------------- | ----- |
  342. # reStructuredText supported targets should be:
  343. # e.g. ================= =====
  344. # Supported Targets ESP32
  345. # ================= =====
  346. def get_md_or_rst(app_path):
  347. readme_path = os.path.join(app_path, 'README.md')
  348. if not os.path.exists(readme_path):
  349. readme_path = os.path.join(app_path, 'README.rst')
  350. if not os.path.exists(readme_path):
  351. return None
  352. return readme_path
  353. readme_path = get_md_or_rst(app_path)
  354. # Handle sub apps situation, e.g. master-slave
  355. if not readme_path:
  356. readme_path = get_md_or_rst(os.path.dirname(app_path))
  357. if not readme_path:
  358. return None
  359. with open(readme_path, 'r', encoding='utf8') as readme_file:
  360. return readme_file.read()
  361. @classmethod
  362. def _supported_targets(cls, app_path):
  363. readme_file_content = BuildSystem._read_readme(app_path)
  364. if not readme_file_content:
  365. return cls.FORMAL_TO_USUAL.values() # supports all targets if no readme found
  366. match = re.findall(BuildSystem.SUPPORTED_TARGETS_REGEX, readme_file_content)
  367. if not match:
  368. return cls.FORMAL_TO_USUAL.values() # supports all targets if no such header in readme
  369. if len(match) > 1:
  370. raise NotImplementedError("Can't determine the value of SUPPORTED_TARGETS in {}".format(app_path))
  371. support_str = match[0].strip()
  372. targets = []
  373. for part in support_str.split('|'):
  374. for inner in part.split(' '):
  375. inner = inner.strip()
  376. if not inner:
  377. continue
  378. elif inner in cls.FORMAL_TO_USUAL:
  379. targets.append(cls.FORMAL_TO_USUAL[inner])
  380. else:
  381. raise NotImplementedError("Can't recognize value of target {} in {}, now we only support '{}'"
  382. .format(inner, app_path, ', '.join(cls.FORMAL_TO_USUAL.keys())))
  383. return targets
  384. @classmethod
  385. @abstractmethod
  386. def supported_targets(cls, app_path):
  387. pass
  388. class BuildError(RuntimeError):
  389. pass
  390. def setup_logging(args):
  391. """
  392. Configure logging module according to the number of '--verbose'/'-v' arguments and the --log-file argument.
  393. :param args: namespace obtained from argparse
  394. """
  395. if not args.verbose:
  396. log_level = logging.WARNING
  397. elif args.verbose == 1:
  398. log_level = logging.INFO
  399. else:
  400. log_level = logging.DEBUG
  401. logging.basicConfig(
  402. format='%(levelname)s: %(message)s',
  403. stream=args.log_file or sys.stderr,
  404. level=log_level,
  405. )