tools.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715
  1. # SPDX-FileCopyrightText: 2022-2023 Espressif Systems (Shanghai) CO LTD
  2. # SPDX-License-Identifier: Apache-2.0
  3. import asyncio
  4. import json
  5. import os
  6. import re
  7. import subprocess
  8. import sys
  9. from asyncio.subprocess import Process
  10. from io import open
  11. from types import FunctionType
  12. from typing import Any, Dict, Generator, List, Match, Optional, TextIO, Tuple, Union
  13. import click
  14. import yaml
  15. from esp_idf_monitor import get_ansi_converter
  16. from idf_py_actions.errors import NoSerialPortFoundError
  17. from .constants import GENERATORS
  18. from .errors import FatalError
  19. # Name of the program, normally 'idf.py'.
  20. # Can be overridden from idf.bat using IDF_PY_PROGRAM_NAME
  21. PROG = os.getenv('IDF_PY_PROGRAM_NAME', 'idf.py')
  22. # environment variable used during click shell completion run
  23. SHELL_COMPLETE_VAR = '_IDF.PY_COMPLETE'
  24. # was shell completion invoked?
  25. SHELL_COMPLETE_RUN = SHELL_COMPLETE_VAR in os.environ
  26. def executable_exists(args: List) -> bool:
  27. try:
  28. subprocess.check_output(args)
  29. return True
  30. except Exception:
  31. return False
  32. def _idf_version_from_cmake() -> Optional[str]:
  33. version_path = os.path.join(os.environ['IDF_PATH'], 'tools/cmake/version.cmake')
  34. regex = re.compile(r'^\s*set\s*\(\s*IDF_VERSION_([A-Z]{5})\s+(\d+)')
  35. ver = {}
  36. try:
  37. with open(version_path) as f:
  38. for line in f:
  39. m = regex.match(line)
  40. if m:
  41. ver[m.group(1)] = m.group(2)
  42. return 'v%s.%s.%s' % (ver['MAJOR'], ver['MINOR'], ver['PATCH'])
  43. except (KeyError, OSError):
  44. sys.stderr.write('WARNING: Cannot find ESP-IDF version in version.cmake\n')
  45. return None
  46. def get_target(path: str, sdkconfig_filename: str='sdkconfig') -> Optional[str]:
  47. path = os.path.join(path, sdkconfig_filename)
  48. return get_sdkconfig_value(path, 'CONFIG_IDF_TARGET')
  49. def idf_version() -> Optional[str]:
  50. """Print version of ESP-IDF"""
  51. # Try to get version from git:
  52. try:
  53. version: Optional[str] = subprocess.check_output([
  54. 'git',
  55. '--git-dir=%s' % os.path.join(os.environ['IDF_PATH'], '.git'),
  56. '--work-tree=%s' % os.environ['IDF_PATH'],
  57. 'describe', '--tags', '--dirty', '--match', 'v*.*',
  58. ]).decode('utf-8', 'ignore').strip()
  59. except (subprocess.CalledProcessError, UnicodeError):
  60. # if failed, then try to parse cmake.version file
  61. sys.stderr.write('WARNING: Git version unavailable, reading from source\n')
  62. version = _idf_version_from_cmake()
  63. return version
  64. def get_default_serial_port() -> Any:
  65. # Import is done here in order to move it after the check_environment()
  66. # ensured that pyserial has been installed
  67. try:
  68. import esptool
  69. import serial.tools.list_ports
  70. ports = list(sorted(p.device for p in serial.tools.list_ports.comports()))
  71. # high baud rate could cause the failure of creation of the connection
  72. esp = esptool.get_default_connected_device(serial_list=ports, port=None, connect_attempts=4,
  73. initial_baud=115200)
  74. if esp is None:
  75. raise NoSerialPortFoundError(
  76. "No serial ports found. Connect a device, or use '-p PORT' option to set a specific port.")
  77. serial_port = esp.serial_port
  78. esp._port.close()
  79. return serial_port
  80. except NoSerialPortFoundError:
  81. raise
  82. except Exception as e:
  83. raise FatalError('An exception occurred during detection of the serial port: {}'.format(e))
  84. # function prints warning when autocompletion is not being performed
  85. # set argument stream to sys.stderr for errors and exceptions
  86. def print_warning(message: str, stream: TextIO=None) -> None:
  87. if not SHELL_COMPLETE_RUN:
  88. print(message, file=stream or sys.stderr)
  89. def color_print(message: str, color: str, newline: Optional[str]='\n') -> None:
  90. """ Print a message to stderr with colored highlighting """
  91. ansi_normal = '\033[0m'
  92. sys.stderr.write('%s%s%s%s' % (color, message, ansi_normal, newline))
  93. sys.stderr.flush()
  94. def yellow_print(message: str, newline: Optional[str]='\n') -> None:
  95. ansi_yellow = '\033[0;33m'
  96. color_print(message, ansi_yellow, newline)
  97. def red_print(message: str, newline: Optional[str]='\n') -> None:
  98. ansi_red = '\033[1;31m'
  99. color_print(message, ansi_red, newline)
  100. def debug_print_idf_version() -> None:
  101. print_warning(f'ESP-IDF {idf_version() or "version unknown"}')
  102. def load_hints() -> Any:
  103. """Helper function to load hints yml file"""
  104. with open(os.path.join(os.path.dirname(__file__), 'hints.yml'), 'r') as file:
  105. hints = yaml.safe_load(file)
  106. return hints
  107. def generate_hints_buffer(output: str, hints: list) -> Generator:
  108. """Helper function to process hints within a string buffer"""
  109. for hint in hints:
  110. variables_list = hint.get('variables')
  111. hint_list, hint_vars, re_vars = [], [], []
  112. match: Optional[Match[str]] = None
  113. try:
  114. if variables_list:
  115. for variables in variables_list:
  116. hint_vars = variables['hint_variables']
  117. re_vars = variables['re_variables']
  118. regex = hint['re'].format(*re_vars)
  119. if re.compile(regex).search(output):
  120. try:
  121. hint_list.append(hint['hint'].format(*hint_vars))
  122. except KeyError as e:
  123. red_print('Argument {} missing in {}. Check hints.yml file.'.format(e, hint))
  124. sys.exit(1)
  125. else:
  126. match = re.compile(hint['re']).search(output)
  127. except KeyError as e:
  128. red_print('Argument {} missing in {}. Check hints.yml file.'.format(e, hint))
  129. sys.exit(1)
  130. except re.error as e:
  131. red_print('{} from hints.yml have {} problem. Check hints.yml file.'.format(hint['re'], e))
  132. sys.exit(1)
  133. if hint_list:
  134. for message in hint_list:
  135. yield ' '.join(['HINT:', message])
  136. elif match:
  137. extra_info = ', '.join(match.groups()) if hint.get('match_to_output', '') else ''
  138. try:
  139. yield ' '.join(['HINT:', hint['hint'].format(extra_info)])
  140. except KeyError:
  141. raise KeyError("Argument 'hint' missing in {}. Check hints.yml file.".format(hint))
  142. def generate_hints(*filenames: str) -> Generator:
  143. """Getting output files and printing hints on how to resolve errors based on the output."""
  144. hints = load_hints()
  145. for file_name in filenames:
  146. with open(file_name, 'r') as file:
  147. output = ' '.join(line.strip() for line in file if line.strip())
  148. yield from generate_hints_buffer(output, hints)
  149. def fit_text_in_terminal(out: str) -> str:
  150. """Fit text in terminal, if the string is not fit replace center with `...`"""
  151. space_for_dots = 3 # Space for "..."
  152. terminal_width, _ = os.get_terminal_size()
  153. if not terminal_width:
  154. return out
  155. if terminal_width <= space_for_dots:
  156. # if the wide of the terminal is too small just print dots
  157. return '.' * terminal_width
  158. if len(out) >= terminal_width:
  159. elide_size = (terminal_width - space_for_dots) // 2
  160. # cut out the middle part of the output if it does not fit in the terminal
  161. return '...'.join([out[:elide_size], out[len(out) - elide_size:]])
  162. return out
  163. class RunTool:
  164. def __init__(self, tool_name: str, args: List, cwd: str, env: Dict=None, custom_error_handler: FunctionType=None, build_dir: str=None,
  165. hints: bool=True, force_progression: bool=False, interactive: bool=False, convert_output: bool=False) -> None:
  166. self.tool_name = tool_name
  167. self.args = args
  168. self.cwd = cwd
  169. self.env = env
  170. self.custom_error_handler = custom_error_handler
  171. # build_dir sets by tools that do not use build directory as cwd
  172. self.build_dir = build_dir or cwd
  173. self.hints = hints
  174. self.force_progression = force_progression
  175. self.interactive = interactive
  176. self.convert_output = convert_output
  177. def __call__(self) -> None:
  178. def quote_arg(arg: str) -> str:
  179. """ Quote the `arg` with whitespace in them because it can cause problems when we call it from a subprocess."""
  180. if re.match(r"^(?![\'\"]).*\s.*", arg):
  181. return ''.join(["'", arg, "'"])
  182. return arg
  183. self.args = [str(arg) for arg in self.args]
  184. display_args = ' '.join(quote_arg(arg) for arg in self.args)
  185. print('Running %s in directory %s' % (self.tool_name, quote_arg(self.cwd)))
  186. print('Executing "%s"...' % str(display_args))
  187. env_copy = dict(os.environ)
  188. env_copy.update(self.env or {})
  189. process: Union[Process, subprocess.CompletedProcess[bytes]]
  190. if self.hints:
  191. process, stderr_output_file, stdout_output_file = asyncio.run(self.run_command(self.args, env_copy))
  192. else:
  193. process = subprocess.run(self.args, env=env_copy, cwd=self.cwd)
  194. stderr_output_file, stdout_output_file = None, None
  195. if process.returncode == 0:
  196. return
  197. if self.custom_error_handler:
  198. self.custom_error_handler(process.returncode, stderr_output_file, stdout_output_file)
  199. return
  200. if stderr_output_file and stdout_output_file:
  201. # hints in interactive mode were already processed, don't print them again
  202. if not self.interactive:
  203. for hint in generate_hints(stderr_output_file, stdout_output_file):
  204. yellow_print(hint)
  205. raise FatalError('{} failed with exit code {}, output of the command is in the {} and {}'.format(self.tool_name, process.returncode,
  206. stderr_output_file, stdout_output_file))
  207. raise FatalError('{} failed with exit code {}'.format(self.tool_name, process.returncode))
  208. async def run_command(self, cmd: List, env_copy: Dict) -> Tuple[Process, Optional[str], Optional[str]]:
  209. """ Run the `cmd` command with capturing stderr and stdout from that function and return returncode
  210. and of the command, the id of the process, paths to captured output """
  211. log_dir_name = 'log'
  212. try:
  213. os.mkdir(os.path.join(self.build_dir, log_dir_name))
  214. except FileExistsError:
  215. pass
  216. # Note: we explicitly pass in os.environ here, as we may have set IDF_PATH there during startup
  217. # limit was added for avoiding error in idf.py confserver
  218. try:
  219. p = await asyncio.create_subprocess_exec(*cmd, env=env_copy, limit=1024 * 256, cwd=self.cwd, stdout=asyncio.subprocess.PIPE,
  220. stderr=asyncio.subprocess.PIPE)
  221. except NotImplementedError:
  222. sys.exit(f'ERROR: {sys.executable} doesn\'t support asyncio. The issue can be worked around by re-running idf.py with the "--no-hints" argument.')
  223. stderr_output_file = os.path.join(self.build_dir, log_dir_name, f'idf_py_stderr_output_{p.pid}')
  224. stdout_output_file = os.path.join(self.build_dir, log_dir_name, f'idf_py_stdout_output_{p.pid}')
  225. if p.stderr and p.stdout: # it only to avoid None type in p.std
  226. await asyncio.gather(
  227. self.read_and_write_stream(p.stderr, stderr_output_file, sys.stderr),
  228. self.read_and_write_stream(p.stdout, stdout_output_file, sys.stdout))
  229. await p.wait() # added for avoiding None returncode
  230. return p, stderr_output_file, stdout_output_file
  231. async def read_and_write_stream(self, input_stream: asyncio.StreamReader, output_filename: str,
  232. output_stream: TextIO) -> None:
  233. """read the output of the `input_stream` and then write it into `output_filename` and `output_stream`"""
  234. def delete_ansi_escape(text: str) -> str:
  235. ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
  236. return ansi_escape.sub('', text)
  237. def print_progression(output: str) -> None:
  238. # Print a new line on top of the previous line
  239. print('\r' + fit_text_in_terminal(output.strip('\n\r')) + '\x1b[K', end='', file=output_stream)
  240. output_stream.flush()
  241. def is_progression(output: str) -> bool:
  242. # try to find possible progression by a pattern match
  243. if re.match(r'^\[\d+/\d+\]|.*\(\d+ \%\)$', output):
  244. return True
  245. return False
  246. async def read_stream() -> Optional[str]:
  247. try:
  248. output_b = await input_stream.readline()
  249. return output_b.decode(errors='ignore')
  250. except (asyncio.LimitOverrunError, asyncio.IncompleteReadError) as e:
  251. print(e, file=sys.stderr)
  252. return None
  253. except AttributeError:
  254. return None
  255. async def read_interactive_stream() -> Optional[str]:
  256. buffer = b''
  257. while True:
  258. output_b = await input_stream.read(1)
  259. if not output_b:
  260. return None
  261. try:
  262. return (buffer + output_b).decode()
  263. except UnicodeDecodeError:
  264. buffer += output_b
  265. if len(buffer) > 4:
  266. # Multi-byte character contain up to 4 bytes and if buffer have more then 4 bytes
  267. # and still can not decode it we can just ignore some bytes
  268. return buffer.decode(errors='replace')
  269. # use ANSI color converter for Monitor on Windows
  270. output_converter = get_ansi_converter(output_stream) if self.convert_output else output_stream
  271. # used in interactive mode to print hints after matched line
  272. hints = load_hints()
  273. last_line = ''
  274. is_progression_last_line = False
  275. is_progression_processing_enabled = self.force_progression and output_stream.isatty() and '-v' not in self.args
  276. try:
  277. with open(output_filename, 'w', encoding='utf8') as output_file:
  278. while True:
  279. if self.interactive:
  280. output = await read_interactive_stream()
  281. else:
  282. output = await read_stream()
  283. if not output:
  284. break
  285. output_noescape = delete_ansi_escape(output)
  286. # Always remove escape sequences when writing the build log.
  287. output_file.write(output_noescape)
  288. # If idf.py output is redirected and the output stream is not a TTY,
  289. # strip the escape sequences as well.
  290. # (There shouldn't be any, but just in case.)
  291. if not output_stream.isatty():
  292. output = output_noescape
  293. if is_progression_processing_enabled and is_progression(output):
  294. print_progression(output)
  295. is_progression_last_line = True
  296. else:
  297. if is_progression_last_line:
  298. output_converter.write(os.linesep)
  299. is_progression_last_line = False
  300. output_converter.write(output)
  301. output_converter.flush()
  302. # process hints for last line and print them right away
  303. if self.interactive:
  304. last_line += output
  305. if last_line[-1] == '\n':
  306. for hint in generate_hints_buffer(last_line, hints):
  307. yellow_print(hint)
  308. last_line = ''
  309. except (RuntimeError, EnvironmentError) as e:
  310. yellow_print('WARNING: The exception {} was raised and we can\'t capture all your {} and '
  311. 'hints on how to resolve errors can be not accurate.'.format(e, output_stream.name.strip('<>')))
  312. def run_tool(*args: Any, **kwargs: Any) -> None:
  313. # Added in case someone uses run_tool externally in idf.py extensions
  314. return RunTool(*args, **kwargs)()
  315. def run_target(target_name: str, args: 'PropertyDict', env: Optional[Dict]=None,
  316. custom_error_handler: FunctionType=None, force_progression: bool=False, interactive: bool=False) -> None:
  317. """Run target in build directory."""
  318. if env is None:
  319. env = {}
  320. generator_cmd = GENERATORS[args.generator]['command']
  321. if args.verbose:
  322. generator_cmd += [GENERATORS[args.generator]['verbose_flag']]
  323. # By default, GNU Make and Ninja strip away color escape sequences when they see that their stdout is redirected.
  324. # If idf.py's stdout is not redirected, the final output is a TTY, so we can tell Make/Ninja to disable stripping
  325. # of color escape sequences. (Requires Ninja v1.9.0 or later.)
  326. if sys.stdout.isatty():
  327. if 'CLICOLOR_FORCE' not in env:
  328. env['CLICOLOR_FORCE'] = '1'
  329. RunTool(generator_cmd[0], generator_cmd + [target_name], args.build_dir, env, custom_error_handler, hints=not args.no_hints,
  330. force_progression=force_progression, interactive=interactive)()
  331. def _strip_quotes(value: str, regexp: re.Pattern=re.compile(r"^\"(.*)\"$|^'(.*)'$|^(.*)$")) -> Optional[str]:
  332. """
  333. Strip quotes like CMake does during parsing cache entries
  334. """
  335. matching_values = regexp.match(value)
  336. return [x for x in matching_values.groups() if x is not None][0].rstrip() if matching_values is not None else None
  337. def _parse_cmakecache(path: str) -> Dict:
  338. """
  339. Parse the CMakeCache file at 'path'.
  340. Returns a dict of name:value.
  341. CMakeCache entries also each have a "type", but this is currently ignored.
  342. """
  343. result = {}
  344. with open(path, encoding='utf-8') as f:
  345. for line in f:
  346. # cmake cache lines look like: CMAKE_CXX_FLAGS_DEBUG:STRING=-g
  347. # groups are name, type, value
  348. m = re.match(r'^([^#/:=]+):([^:=]+)=(.*)\n$', line)
  349. if m:
  350. result[m.group(1)] = m.group(3)
  351. return result
  352. def _parse_cmdl_cmakecache(entries: List) -> Dict[str, str]:
  353. """
  354. Parse list of CMake cache entries passed in via the -D option.
  355. Returns a dict of name:value.
  356. """
  357. result: Dict = {}
  358. for entry in entries:
  359. key, value = entry.split('=', 1)
  360. value = _strip_quotes(value)
  361. result[key] = value
  362. return result
  363. def _new_cmakecache_entries(cache: Dict, cache_cmdl: Dict) -> bool:
  364. for entry in cache_cmdl:
  365. if entry not in cache:
  366. return True
  367. if cache_cmdl[entry] != cache[entry]:
  368. return True
  369. return False
  370. def _detect_cmake_generator(prog_name: str) -> Any:
  371. """
  372. Find the default cmake generator, if none was specified. Raises an exception if no valid generator is found.
  373. """
  374. for (generator_name, generator) in GENERATORS.items():
  375. if executable_exists(generator['version']):
  376. return generator_name
  377. raise FatalError("To use %s, either the 'ninja' or 'GNU make' build tool must be available in the PATH" % prog_name)
  378. def ensure_build_directory(args: 'PropertyDict', prog_name: str, always_run_cmake: bool=False,
  379. env: Dict=None) -> None:
  380. """Check the build directory exists and that cmake has been run there.
  381. If this isn't the case, create the build directory (if necessary) and
  382. do an initial cmake run to configure it.
  383. This function will also check args.generator parameter. If the parameter is incompatible with
  384. the build directory, an error is raised. If the parameter is None, this function will set it to
  385. an auto-detected default generator or to the value already configured in the build directory.
  386. """
  387. if not executable_exists(['cmake', '--version']):
  388. debug_print_idf_version()
  389. raise FatalError(f'"cmake" must be available on the PATH to use {PROG}')
  390. project_dir = args.project_dir
  391. # Verify the project directory
  392. if not os.path.isdir(project_dir):
  393. if not os.path.exists(project_dir):
  394. raise FatalError('Project directory %s does not exist' % project_dir)
  395. else:
  396. raise FatalError('%s must be a project directory' % project_dir)
  397. if not os.path.exists(os.path.join(project_dir, 'CMakeLists.txt')):
  398. raise FatalError('CMakeLists.txt not found in project directory %s' % project_dir)
  399. # Verify/create the build directory
  400. build_dir = args.build_dir
  401. if not os.path.isdir(build_dir):
  402. os.makedirs(build_dir)
  403. # Parse CMakeCache, if it exists
  404. cache_path = os.path.join(build_dir, 'CMakeCache.txt')
  405. cache = _parse_cmakecache(cache_path) if os.path.exists(cache_path) else {}
  406. args.define_cache_entry.append('CCACHE_ENABLE=%d' % args.ccache)
  407. cache_cmdl = _parse_cmdl_cmakecache(args.define_cache_entry)
  408. # Validate IDF_TARGET
  409. _check_idf_target(args, prog_name, cache, cache_cmdl, env)
  410. if always_run_cmake or _new_cmakecache_entries(cache, cache_cmdl):
  411. if args.generator is None:
  412. args.generator = _detect_cmake_generator(prog_name)
  413. try:
  414. cmake_args = [
  415. 'cmake',
  416. '-G',
  417. args.generator,
  418. '-DPYTHON_DEPS_CHECKED=1',
  419. '-DPYTHON={}'.format(sys.executable),
  420. '-DESP_PLATFORM=1',
  421. ]
  422. if args.cmake_warn_uninitialized:
  423. cmake_args += ['--warn-uninitialized']
  424. if args.define_cache_entry:
  425. cmake_args += ['-D' + d for d in args.define_cache_entry]
  426. cmake_args += [project_dir]
  427. hints = not args.no_hints
  428. RunTool('cmake', cmake_args, cwd=args.build_dir, env=env, hints=hints)()
  429. except Exception:
  430. # don't allow partially valid CMakeCache.txt files,
  431. # to keep the "should I run cmake?" logic simple
  432. if os.path.exists(cache_path):
  433. os.remove(cache_path)
  434. raise
  435. # need to update cache so subsequent access in this method would reflect the result of the previous cmake run
  436. cache = _parse_cmakecache(cache_path) if os.path.exists(cache_path) else {}
  437. try:
  438. generator = cache['CMAKE_GENERATOR']
  439. except KeyError:
  440. generator = _detect_cmake_generator(prog_name)
  441. if args.generator is None:
  442. args.generator = (generator) # reuse the previously configured generator, if none was given
  443. if generator != args.generator:
  444. raise FatalError("Build is configured for generator '%s' not '%s'. Run '%s fullclean' to start again." %
  445. (generator, args.generator, prog_name))
  446. try:
  447. home_dir = cache['CMAKE_HOME_DIRECTORY']
  448. if os.path.realpath(home_dir) != os.path.realpath(project_dir):
  449. raise FatalError(
  450. "Build directory '%s' configured for project '%s' not '%s'. Run '%s fullclean' to start again." %
  451. (build_dir, os.path.realpath(home_dir), os.path.realpath(project_dir), prog_name))
  452. except KeyError:
  453. pass # if cmake failed part way, CMAKE_HOME_DIRECTORY may not be set yet
  454. try:
  455. python = cache['PYTHON']
  456. if python != sys.executable:
  457. raise FatalError(
  458. "'{}' is currently active in the environment while the project was configured with '{}'. "
  459. "Run '{} fullclean' to start again.".format(sys.executable, python, prog_name))
  460. except KeyError:
  461. pass
  462. def merge_action_lists(*action_lists: Dict) -> Dict:
  463. merged_actions: Dict = {
  464. 'global_options': [],
  465. 'actions': {},
  466. 'global_action_callbacks': [],
  467. }
  468. for action_list in action_lists:
  469. merged_actions['global_options'].extend(action_list.get('global_options', []))
  470. merged_actions['actions'].update(action_list.get('actions', {}))
  471. merged_actions['global_action_callbacks'].extend(action_list.get('global_action_callbacks', []))
  472. return merged_actions
  473. def get_sdkconfig_filename(args: 'PropertyDict', cache_cmdl: Dict=None) -> str:
  474. """
  475. Get project's sdkconfig file name.
  476. """
  477. if not cache_cmdl:
  478. cache_cmdl = _parse_cmdl_cmakecache(args.define_cache_entry)
  479. config = cache_cmdl.get('SDKCONFIG')
  480. if config:
  481. return os.path.abspath(config)
  482. proj_desc_path = os.path.join(args.build_dir, 'project_description.json')
  483. try:
  484. with open(proj_desc_path, 'r') as f:
  485. proj_desc = json.load(f)
  486. return str(proj_desc['config_file'])
  487. except (OSError, KeyError):
  488. pass
  489. return os.path.join(args.project_dir, 'sdkconfig')
  490. def get_sdkconfig_value(sdkconfig_file: str, key: str) -> Optional[str]:
  491. """
  492. Return the value of given key from sdkconfig_file.
  493. If sdkconfig_file does not exist or the option is not present, returns None.
  494. """
  495. assert key.startswith('CONFIG_')
  496. if not os.path.exists(sdkconfig_file):
  497. return None
  498. # keep track of the last seen value for the given key
  499. value = None
  500. # if the value is quoted, this excludes the quotes from the value
  501. pattern = re.compile(r"^{}=\"?([^\"]*)\"?$".format(key))
  502. with open(sdkconfig_file, 'r') as f:
  503. for line in f:
  504. match = re.match(pattern, line)
  505. if match:
  506. value = match.group(1)
  507. return value
  508. def is_target_supported(project_path: str, supported_targets: List) -> bool:
  509. """
  510. Returns True if the active target is supported, or False otherwise.
  511. """
  512. return get_target(project_path) in supported_targets
  513. def _check_idf_target(args: 'PropertyDict', prog_name: str, cache: Dict,
  514. cache_cmdl: Dict, env: Dict=None) -> None:
  515. """
  516. Cross-check the three settings (sdkconfig, CMakeCache, environment) and if there is
  517. mismatch, fail with instructions on how to fix this.
  518. """
  519. sdkconfig = get_sdkconfig_filename(args, cache_cmdl)
  520. idf_target_from_sdkconfig = get_sdkconfig_value(sdkconfig, 'CONFIG_IDF_TARGET')
  521. idf_target_from_env = os.environ.get('IDF_TARGET')
  522. idf_target_from_cache = cache.get('IDF_TARGET')
  523. idf_target_from_cache_cmdl = cache_cmdl.get('IDF_TARGET')
  524. # Called from set-target action. The original sdkconfig will be renamed
  525. # in cmake, so ignore any CONFIG_IDF_TARGET which may be defined in
  526. # stale sdkconfig.
  527. if env and env.get('_IDF_PY_SET_TARGET_ACTION') == '1':
  528. idf_target_from_sdkconfig = None
  529. if idf_target_from_env:
  530. # Let's check that IDF_TARGET values are consistent
  531. if idf_target_from_sdkconfig and idf_target_from_sdkconfig != idf_target_from_env:
  532. raise FatalError("Project sdkconfig '{cfg}' was generated for target '{t_conf}', but environment variable IDF_TARGET "
  533. "is set to '{t_env}'. Run '{prog} set-target {t_env}' to generate new sdkconfig file for target {t_env}."
  534. .format(cfg=sdkconfig, t_conf=idf_target_from_sdkconfig, t_env=idf_target_from_env, prog=prog_name))
  535. if idf_target_from_cache and idf_target_from_cache != idf_target_from_env:
  536. raise FatalError("Target settings are not consistent: '{t_env}' in the environment, '{t_cache}' in CMakeCache.txt. "
  537. "Run '{prog} fullclean' to start again."
  538. .format(t_env=idf_target_from_env, t_cache=idf_target_from_cache, prog=prog_name))
  539. if idf_target_from_cache_cmdl and idf_target_from_cache_cmdl != idf_target_from_env:
  540. raise FatalError("Target '{t_cmdl}' specified on command line is not consistent with "
  541. "target '{t_env}' in the environment."
  542. .format(t_cmdl=idf_target_from_cache_cmdl, t_env=idf_target_from_env))
  543. elif idf_target_from_cache_cmdl:
  544. # Check if -DIDF_TARGET is consistent with target in CMakeCache.txt
  545. if idf_target_from_cache and idf_target_from_cache != idf_target_from_cache_cmdl:
  546. raise FatalError("Target '{t_cmdl}' specified on command line is not consistent with "
  547. "target '{t_cache}' in CMakeCache.txt. Run '{prog} set-target {t_cmdl}' to re-generate "
  548. 'CMakeCache.txt.'
  549. .format(t_cache=idf_target_from_cache, t_cmdl=idf_target_from_cache_cmdl, prog=prog_name))
  550. elif idf_target_from_cache:
  551. # This shouldn't happen, unless the user manually edits CMakeCache.txt or sdkconfig, but let's check anyway.
  552. if idf_target_from_sdkconfig and idf_target_from_cache != idf_target_from_sdkconfig:
  553. raise FatalError("Project sdkconfig '{cfg}' was generated for target '{t_conf}', but CMakeCache.txt contains '{t_cache}'. "
  554. "To keep the setting in sdkconfig ({t_conf}) and re-generate CMakeCache.txt, run '{prog} fullclean'. "
  555. "To re-generate sdkconfig for '{t_cache}' target, run '{prog} set-target {t_cache}'."
  556. .format(cfg=sdkconfig, t_conf=idf_target_from_sdkconfig, t_cache=idf_target_from_cache, prog=prog_name))
  557. class TargetChoice(click.Choice):
  558. """
  559. A version of click.Choice with two special features:
  560. - ignores hyphens
  561. - not case sensitive
  562. """
  563. def __init__(self, choices: List) -> None:
  564. super(TargetChoice, self).__init__(choices, case_sensitive=False)
  565. def convert(self, value: Any, param: click.Parameter, ctx: click.Context) -> Any:
  566. def normalize(string: str) -> str:
  567. return string.lower().replace('-', '')
  568. saved_token_normalize_func = ctx.token_normalize_func
  569. ctx.token_normalize_func = normalize
  570. try:
  571. return super(TargetChoice, self).convert(value, param, ctx)
  572. finally:
  573. ctx.token_normalize_func = saved_token_normalize_func
  574. class PropertyDict(dict):
  575. def __getattr__(self, name: str) -> Any:
  576. if name in self:
  577. return self[name]
  578. else:
  579. raise AttributeError("'PropertyDict' object has no attribute '%s'" % name)
  580. def __setattr__(self, name: str, value: Any) -> None:
  581. self[name] = value
  582. def __delattr__(self, name: str) -> None:
  583. if name in self:
  584. del self[name]
  585. else:
  586. raise AttributeError("'PropertyDict' object has no attribute '%s'" % name)