1#!/usr/bin/env python 2# coding=utf-8 3# 4# This script helps installing tools required to use the ESP-IDF, and updating PATH 5# to use the installed tools. It can also create a Python virtual environment, 6# and install Python requirements into it. 7# It does not install OS dependencies. It does install tools such as the Xtensa 8# GCC toolchain and ESP32 ULP coprocessor toolchain. 9# 10# By default, downloaded tools will be installed under $HOME/.espressif directory 11# (%USERPROFILE%/.espressif on Windows). This path can be modified by setting 12# IDF_TOOLS_PATH variable prior to running this tool. 13# 14# Users do not need to interact with this script directly. In IDF root directory, 15# install.sh (.bat) and export.sh (.bat) scripts are provided to invoke this script. 16# 17# Usage: 18# 19# * To install the tools, run `idf_tools.py install`. 20# 21# * To install the Python environment, run `idf_tools.py install-python-env`. 22# 23# * To start using the tools, run `eval "$(idf_tools.py export)"` — this will update 24# the PATH to point to the installed tools and set up other environment variables 25# needed by the tools. 26# 27### 28# 29# Copyright 2019 Espressif Systems (Shanghai) PTE LTD 30# 31# Licensed under the Apache License, Version 2.0 (the "License"); 32# you may not use this file except in compliance with the License. 33# You may obtain a copy of the License at 34# 35# http://www.apache.org/licenses/LICENSE-2.0 36# 37# Unless required by applicable law or agreed to in writing, software 38# distributed under the License is distributed on an "AS IS" BASIS, 39# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 40# See the License for the specific language governing permissions and 41# limitations under the License. 42 43import argparse 44import contextlib 45import copy 46import errno 47import functools 48import hashlib 49import json 50import os 51import platform 52import re 53import shutil 54import ssl 55import subprocess 56import sys 57import tarfile 58import zipfile 59from collections import OrderedDict, namedtuple 60 61try: 62 import typing # noqa: F401 63except ImportError: 64 pass 65 66try: 67 from urllib.error import ContentTooShortError 68 from urllib.request import urlopen 69except ImportError: 70 from urllib import ContentTooShortError, urlopen 71 72try: 73 from exceptions import WindowsError 74except ImportError: 75 class WindowsError(OSError): 76 pass 77 78 79TOOLS_FILE = 'tools/tools.json' 80TOOLS_SCHEMA_FILE = 'tools/tools_schema.json' 81TOOLS_FILE_NEW = 'tools/tools.new.json' 82TOOLS_FILE_VERSION = 1 83IDF_TOOLS_PATH_DEFAULT = os.path.join('~', '.espressif') 84UNKNOWN_VERSION = 'unknown' 85SUBST_TOOL_PATH_REGEX = re.compile(r'\${TOOL_PATH}') 86VERSION_REGEX_REPLACE_DEFAULT = r'\1' 87IDF_MAINTAINER = os.environ.get('IDF_MAINTAINER') or False 88TODO_MESSAGE = 'TODO' 89DOWNLOAD_RETRY_COUNT = 3 90URL_PREFIX_MAP_SEPARATOR = ',' 91IDF_TOOLS_INSTALL_CMD = os.environ.get('IDF_TOOLS_INSTALL_CMD') 92IDF_TOOLS_EXPORT_CMD = os.environ.get('IDF_TOOLS_INSTALL_CMD') 93 94PYTHON_PLATFORM = platform.system() + '-' + platform.machine() 95 96# Identifiers used in tools.json for different platforms. 97PLATFORM_WIN32 = 'win32' 98PLATFORM_WIN64 = 'win64' 99PLATFORM_MACOS = 'macos' 100PLATFORM_LINUX32 = 'linux-i686' 101PLATFORM_LINUX64 = 'linux-amd64' 102PLATFORM_LINUX_ARM32 = 'linux-armel' 103PLATFORM_LINUX_ARM64 = 'linux-arm64' 104 105 106# Mappings from various other names these platforms are known as, to the identifiers above. 107# This includes strings produced from "platform.system() + '-' + platform.machine()", see PYTHON_PLATFORM 108# definition above. 109# This list also includes various strings used in release archives of xtensa-esp32-elf-gcc, OpenOCD, etc. 110PLATFORM_FROM_NAME = { 111 # Windows 112 PLATFORM_WIN32: PLATFORM_WIN32, 113 'Windows-i686': PLATFORM_WIN32, 114 'Windows-x86': PLATFORM_WIN32, 115 PLATFORM_WIN64: PLATFORM_WIN64, 116 'Windows-x86_64': PLATFORM_WIN64, 117 'Windows-AMD64': PLATFORM_WIN64, 118 # macOS 119 PLATFORM_MACOS: PLATFORM_MACOS, 120 'osx': PLATFORM_MACOS, 121 'darwin': PLATFORM_MACOS, 122 'Darwin-x86_64': PLATFORM_MACOS, 123 # pretend it is x86_64 until Darwin-arm64 tool builds are available: 124 'Darwin-arm64': PLATFORM_MACOS, 125 # Linux 126 PLATFORM_LINUX64: PLATFORM_LINUX64, 127 'linux64': PLATFORM_LINUX64, 128 'Linux-x86_64': PLATFORM_LINUX64, 129 'FreeBSD-amd64': PLATFORM_LINUX64, 130 PLATFORM_LINUX32: PLATFORM_LINUX32, 131 'linux32': PLATFORM_LINUX32, 132 'Linux-i686': PLATFORM_LINUX32, 133 'FreeBSD-i386': PLATFORM_LINUX32, 134 PLATFORM_LINUX_ARM32: PLATFORM_LINUX_ARM32, 135 'Linux-arm': PLATFORM_LINUX_ARM32, 136 'Linux-armv7l': PLATFORM_LINUX_ARM32, 137 PLATFORM_LINUX_ARM64: PLATFORM_LINUX_ARM64, 138 'Linux-arm64': PLATFORM_LINUX_ARM64, 139 'Linux-aarch64': PLATFORM_LINUX_ARM64, 140 'Linux-armv8l': PLATFORM_LINUX_ARM64, 141} 142 143UNKNOWN_PLATFORM = 'unknown' 144CURRENT_PLATFORM = PLATFORM_FROM_NAME.get(PYTHON_PLATFORM, UNKNOWN_PLATFORM) 145 146EXPORT_SHELL = 'shell' 147EXPORT_KEY_VALUE = 'key-value' 148 149ISRG_X1_ROOT_CERT = u""" 150-----BEGIN CERTIFICATE----- 151MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw 152TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh 153cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 154WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu 155ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY 156MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc 157h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ 1580TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U 159A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW 160T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH 161B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC 162B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv 163KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn 164OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn 165jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw 166qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI 167rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV 168HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq 169hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL 170ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ 1713BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK 172NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 173ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur 174TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC 175jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc 176oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq 1774RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA 178mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d 179emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= 180-----END CERTIFICATE----- 181""" 182 183 184global_quiet = False 185global_non_interactive = False 186global_idf_path = None # type: typing.Optional[str] 187global_idf_tools_path = None # type: typing.Optional[str] 188global_tools_json = None # type: typing.Optional[str] 189 190 191def fatal(text, *args): 192 if not global_quiet: 193 sys.stderr.write('ERROR: ' + text + '\n', *args) 194 195 196def warn(text, *args): 197 if not global_quiet: 198 sys.stderr.write('WARNING: ' + text + '\n', *args) 199 200 201def info(text, f=None, *args): 202 if not global_quiet: 203 if f is None: 204 f = sys.stdout 205 f.write(text + '\n', *args) 206 207 208def run_cmd_check_output(cmd, input_text=None, extra_paths=None): 209 # If extra_paths is given, locate the executable in one of these directories. 210 # Note: it would seem logical to add extra_paths to env[PATH], instead, and let OS do the job of finding the 211 # executable for us. However this does not work on Windows: https://bugs.python.org/issue8557. 212 if extra_paths: 213 found = False 214 extensions = [''] 215 if sys.platform == 'win32': 216 extensions.append('.exe') 217 for path in extra_paths: 218 for ext in extensions: 219 fullpath = os.path.join(path, cmd[0] + ext) 220 if os.path.exists(fullpath): 221 cmd[0] = fullpath 222 found = True 223 break 224 if found: 225 break 226 227 try: 228 if input_text: 229 input_text = input_text.encode() 230 result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, input=input_text) 231 return result.stdout + result.stderr 232 except (AttributeError, TypeError): 233 p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE) 234 stdout, stderr = p.communicate(input_text) 235 if p.returncode != 0: 236 try: 237 raise subprocess.CalledProcessError(p.returncode, cmd, stdout, stderr) 238 except TypeError: 239 raise subprocess.CalledProcessError(p.returncode, cmd, stdout) 240 return stdout + stderr 241 242 243def to_shell_specific_paths(paths_list): 244 if sys.platform == 'win32': 245 paths_list = [p.replace('/', os.path.sep) if os.path.sep in p else p for p in paths_list] 246 247 if 'MSYSTEM' in os.environ: 248 paths_msys = run_cmd_check_output(['cygpath', '-u', '-f', '-'], 249 input_text='\n'.join(paths_list)) 250 paths_list = paths_msys.decode().strip().split('\n') 251 252 return paths_list 253 254 255def get_env_for_extra_paths(extra_paths): 256 """ 257 Return a copy of environment variables dict, prepending paths listed in extra_paths 258 to the PATH environment variable. 259 """ 260 env_arg = os.environ.copy() 261 new_path = os.pathsep.join(extra_paths) + os.pathsep + env_arg['PATH'] 262 if sys.version_info.major == 2: 263 env_arg['PATH'] = new_path.encode('utf8') 264 else: 265 env_arg['PATH'] = new_path 266 return env_arg 267 268 269def get_file_size_sha256(filename, block_size=65536): 270 sha256 = hashlib.sha256() 271 size = 0 272 with open(filename, 'rb') as f: 273 for block in iter(lambda: f.read(block_size), b''): 274 sha256.update(block) 275 size += len(block) 276 return size, sha256.hexdigest() 277 278 279def report_progress(count, block_size, total_size): 280 percent = int(count * block_size * 100 / total_size) 281 percent = min(100, percent) 282 sys.stdout.write('\r%d%%' % percent) 283 sys.stdout.flush() 284 285 286def mkdir_p(path): 287 try: 288 os.makedirs(path) 289 except OSError as exc: 290 if exc.errno != errno.EEXIST or not os.path.isdir(path): 291 raise 292 293 294def unpack(filename, destination): 295 info('Extracting {0} to {1}'.format(filename, destination)) 296 if filename.endswith(('.tar.gz', '.tgz')): 297 archive_obj = tarfile.open(filename, 'r:gz') 298 elif filename.endswith('zip'): 299 archive_obj = zipfile.ZipFile(filename) 300 else: 301 raise NotImplementedError('Unsupported archive type') 302 if sys.version_info.major == 2: 303 # This is a workaround for the issue that unicode destination is not handled: 304 # https://bugs.python.org/issue17153 305 destination = str(destination) 306 archive_obj.extractall(destination) 307 308 309def splittype(url): 310 match = re.match('([^/:]+):(.*)', url, re.DOTALL) 311 if match: 312 scheme, data = match.groups() 313 return scheme.lower(), data 314 return None, url 315 316 317# An alternative version of urlretrieve which takes SSL context as an argument 318def urlretrieve_ctx(url, filename, reporthook=None, data=None, context=None): 319 url_type, path = splittype(url) 320 321 # urlopen doesn't have context argument in Python <=2.7.9 322 extra_urlopen_args = {} 323 if context: 324 extra_urlopen_args['context'] = context 325 with contextlib.closing(urlopen(url, data, **extra_urlopen_args)) as fp: 326 headers = fp.info() 327 328 # Just return the local path and the "headers" for file:// 329 # URLs. No sense in performing a copy unless requested. 330 if url_type == 'file' and not filename: 331 return os.path.normpath(path), headers 332 333 # Handle temporary file setup. 334 tfp = open(filename, 'wb') 335 336 with tfp: 337 result = filename, headers 338 bs = 1024 * 8 339 size = int(headers.get('content-length', -1)) 340 read = 0 341 blocknum = 0 342 343 if reporthook: 344 reporthook(blocknum, bs, size) 345 346 while True: 347 block = fp.read(bs) 348 if not block: 349 break 350 read += len(block) 351 tfp.write(block) 352 blocknum += 1 353 if reporthook: 354 reporthook(blocknum, bs, size) 355 356 if size >= 0 and read < size: 357 raise ContentTooShortError( 358 'retrieval incomplete: got only %i out of %i bytes' 359 % (read, size), result) 360 361 return result 362 363 364# Sometimes renaming a directory on Windows (randomly?) causes a PermissionError. 365# This is confirmed to be a workaround: 366# https://github.com/espressif/esp-idf/issues/3819#issuecomment-515167118 367# https://github.com/espressif/esp-idf/issues/4063#issuecomment-531490140 368# https://stackoverflow.com/a/43046729 369def rename_with_retry(path_from, path_to): 370 if sys.platform.startswith('win'): 371 retry_count = 100 372 else: 373 retry_count = 1 374 375 for retry in range(retry_count): 376 try: 377 os.rename(path_from, path_to) 378 return 379 except (OSError, WindowsError): # WindowsError until Python 3.3, then OSError 380 if retry == retry_count - 1: 381 raise 382 warn('Rename {} to {} failed, retrying...'.format(path_from, path_to)) 383 384 385def strip_container_dirs(path, levels): 386 assert levels > 0 387 # move the original directory out of the way (add a .tmp suffix) 388 tmp_path = path + '.tmp' 389 if os.path.exists(tmp_path): 390 shutil.rmtree(tmp_path) 391 rename_with_retry(path, tmp_path) 392 os.mkdir(path) 393 base_path = tmp_path 394 # walk given number of levels down 395 for level in range(levels): 396 contents = os.listdir(base_path) 397 if len(contents) > 1: 398 raise RuntimeError('at level {}, expected 1 entry, got {}'.format(level, contents)) 399 base_path = os.path.join(base_path, contents[0]) 400 if not os.path.isdir(base_path): 401 raise RuntimeError('at level {}, {} is not a directory'.format(level, contents[0])) 402 # get the list of directories/files to move 403 contents = os.listdir(base_path) 404 for name in contents: 405 move_from = os.path.join(base_path, name) 406 move_to = os.path.join(path, name) 407 rename_with_retry(move_from, move_to) 408 shutil.rmtree(tmp_path) 409 410 411class ToolNotFound(RuntimeError): 412 pass 413 414 415class ToolExecError(RuntimeError): 416 pass 417 418 419class DownloadError(RuntimeError): 420 pass 421 422 423class IDFToolDownload(object): 424 def __init__(self, platform_name, url, size, sha256): 425 self.platform_name = platform_name 426 self.url = url 427 self.size = size 428 self.sha256 = sha256 429 self.platform_name = platform_name 430 431 432@functools.total_ordering 433class IDFToolVersion(object): 434 STATUS_RECOMMENDED = 'recommended' 435 STATUS_SUPPORTED = 'supported' 436 STATUS_DEPRECATED = 'deprecated' 437 438 STATUS_VALUES = [STATUS_RECOMMENDED, STATUS_SUPPORTED, STATUS_DEPRECATED] 439 440 def __init__(self, version, status): 441 self.version = version 442 self.status = status 443 self.downloads = OrderedDict() 444 self.latest = False 445 446 def __lt__(self, other): 447 if self.status != other.status: 448 return self.status > other.status 449 else: 450 assert not (self.status == IDFToolVersion.STATUS_RECOMMENDED 451 and other.status == IDFToolVersion.STATUS_RECOMMENDED) 452 return self.version < other.version 453 454 def __eq__(self, other): 455 return self.status == other.status and self.version == other.version 456 457 def add_download(self, platform_name, url, size, sha256): 458 self.downloads[platform_name] = IDFToolDownload(platform_name, url, size, sha256) 459 460 def get_download_for_platform(self, platform_name): # type: (str) -> IDFToolDownload 461 if platform_name in PLATFORM_FROM_NAME.keys(): 462 platform_name = PLATFORM_FROM_NAME[platform_name] 463 if platform_name in self.downloads.keys(): 464 return self.downloads[platform_name] 465 if 'any' in self.downloads.keys(): 466 return self.downloads['any'] 467 return None 468 469 def compatible_with_platform(self, platform_name=PYTHON_PLATFORM): 470 return self.get_download_for_platform(platform_name) is not None 471 472 def get_supported_platforms(self): # type: () -> typing.Set[str] 473 return set(self.downloads.keys()) 474 475 476OPTIONS_LIST = ['version_cmd', 477 'version_regex', 478 'version_regex_replace', 479 'export_paths', 480 'export_vars', 481 'install', 482 'info_url', 483 'license', 484 'strip_container_dirs'] 485 486IDFToolOptions = namedtuple('IDFToolOptions', OPTIONS_LIST) 487 488 489class IDFTool(object): 490 # possible values of 'install' field 491 INSTALL_ALWAYS = 'always' 492 INSTALL_ON_REQUEST = 'on_request' 493 INSTALL_NEVER = 'never' 494 495 def __init__(self, name, description, install, info_url, license, version_cmd, version_regex, version_regex_replace=None, 496 strip_container_dirs=0): 497 self.name = name 498 self.description = description 499 self.versions = OrderedDict() # type: typing.Dict[str, IDFToolVersion] 500 self.version_in_path = None 501 self.versions_installed = [] 502 if version_regex_replace is None: 503 version_regex_replace = VERSION_REGEX_REPLACE_DEFAULT 504 self.options = IDFToolOptions(version_cmd, version_regex, version_regex_replace, 505 [], OrderedDict(), install, info_url, license, strip_container_dirs) 506 self.platform_overrides = [] 507 self._platform = CURRENT_PLATFORM 508 self._update_current_options() 509 510 def copy_for_platform(self, platform): # type: (str) -> IDFTool 511 result = copy.deepcopy(self) 512 result._platform = platform 513 result._update_current_options() 514 return result 515 516 def _update_current_options(self): 517 self._current_options = IDFToolOptions(*self.options) 518 for override in self.platform_overrides: 519 if self._platform not in override['platforms']: 520 continue 521 override_dict = override.copy() 522 del override_dict['platforms'] 523 self._current_options = self._current_options._replace(**override_dict) 524 525 def add_version(self, version): 526 assert(type(version) is IDFToolVersion) 527 self.versions[version.version] = version 528 529 def get_path(self): # type: () -> str 530 return os.path.join(global_idf_tools_path, 'tools', self.name) 531 532 def get_path_for_version(self, version): # type: (str) -> str 533 assert(version in self.versions) 534 return os.path.join(self.get_path(), version) 535 536 def get_export_paths(self, version): # type: (str) -> typing.List[str] 537 tool_path = self.get_path_for_version(version) 538 return [os.path.join(tool_path, *p) for p in self._current_options.export_paths] 539 540 def get_export_vars(self, version): # type: (str) -> typing.Dict[str] 541 """ 542 Get the dictionary of environment variables to be exported, for the given version. 543 Expands: 544 - ${TOOL_PATH} => the actual path where the version is installed 545 """ 546 result = {} 547 for k, v in self._current_options.export_vars.items(): 548 replace_path = self.get_path_for_version(version).replace('\\', '\\\\') 549 v_repl = re.sub(SUBST_TOOL_PATH_REGEX, replace_path, v) 550 if v_repl != v: 551 v_repl = to_shell_specific_paths([v_repl])[0] 552 result[k] = v_repl 553 return result 554 555 def check_version(self, extra_paths=None): # type: (typing.Optional[typing.List[str]]) -> str 556 """ 557 Execute the tool, optionally prepending extra_paths to PATH, 558 extract the version string and return it as a result. 559 Raises ToolNotFound if the tool is not found (not present in the paths). 560 Raises ToolExecError if the tool returns with a non-zero exit code. 561 Returns 'unknown' if tool returns something from which version string 562 can not be extracted. 563 """ 564 # this function can not be called for a different platform 565 assert self._platform == CURRENT_PLATFORM 566 cmd = self._current_options.version_cmd 567 try: 568 version_cmd_result = run_cmd_check_output(cmd, None, extra_paths) 569 except OSError: 570 # tool is not on the path 571 raise ToolNotFound('Tool {} not found'.format(self.name)) 572 except subprocess.CalledProcessError as e: 573 raise ToolExecError('Command {} has returned non-zero exit code ({})\n'.format( 574 ' '.join(self._current_options.version_cmd), e.returncode)) 575 576 in_str = version_cmd_result.decode('utf-8') 577 match = re.search(self._current_options.version_regex, in_str) 578 if not match: 579 return UNKNOWN_VERSION 580 return re.sub(self._current_options.version_regex, self._current_options.version_regex_replace, match.group(0)) 581 582 def get_install_type(self): 583 return self._current_options.install 584 585 def compatible_with_platform(self): 586 return any([v.compatible_with_platform() for v in self.versions.values()]) 587 588 def get_supported_platforms(self): # type: () -> typing.Set[str] 589 result = set() 590 for v in self.versions.values(): 591 result.update(v.get_supported_platforms()) 592 return result 593 594 def get_recommended_version(self): 595 recommended_versions = [k for k, v in self.versions.items() 596 if v.status == IDFToolVersion.STATUS_RECOMMENDED 597 and v.compatible_with_platform(self._platform)] 598 assert len(recommended_versions) <= 1 599 if recommended_versions: 600 return recommended_versions[0] 601 return None 602 603 def get_preferred_installed_version(self): 604 recommended_versions = [k for k in self.versions_installed 605 if self.versions[k].status == IDFToolVersion.STATUS_RECOMMENDED 606 and self.versions[k].compatible_with_platform(self._platform)] 607 assert len(recommended_versions) <= 1 608 if recommended_versions: 609 return recommended_versions[0] 610 return None 611 612 def find_installed_versions(self): 613 """ 614 Checks whether the tool can be found in PATH and in global_idf_tools_path. 615 Writes results to self.version_in_path and self.versions_installed. 616 """ 617 # this function can not be called for a different platform 618 assert self._platform == CURRENT_PLATFORM 619 # First check if the tool is in system PATH 620 try: 621 ver_str = self.check_version() 622 except ToolNotFound: 623 # not in PATH 624 pass 625 except ToolExecError: 626 warn('tool {} found in path, but failed to run'.format(self.name)) 627 else: 628 self.version_in_path = ver_str 629 630 # Now check all the versions installed in global_idf_tools_path 631 self.versions_installed = [] 632 for version, version_obj in self.versions.items(): 633 if not version_obj.compatible_with_platform(): 634 continue 635 tool_path = self.get_path_for_version(version) 636 if not os.path.exists(tool_path): 637 # version not installed 638 continue 639 try: 640 ver_str = self.check_version(self.get_export_paths(version)) 641 except ToolNotFound: 642 warn('directory for tool {} version {} is present, but tool was not found'.format( 643 self.name, version)) 644 except ToolExecError: 645 warn('tool {} version {} is installed, but the tool failed to run'.format( 646 self.name, version)) 647 else: 648 if ver_str != version: 649 warn('tool {} version {} is installed, but has reported version {}'.format( 650 self.name, version, ver_str)) 651 else: 652 self.versions_installed.append(version) 653 654 def download(self, version): 655 assert(version in self.versions) 656 download_obj = self.versions[version].get_download_for_platform(self._platform) 657 if not download_obj: 658 fatal('No packages for tool {} platform {}!'.format(self.name, self._platform)) 659 raise DownloadError() 660 661 url = download_obj.url 662 archive_name = os.path.basename(url) 663 local_path = os.path.join(global_idf_tools_path, 'dist', archive_name) 664 mkdir_p(os.path.dirname(local_path)) 665 666 if os.path.isfile(local_path): 667 if not self.check_download_file(download_obj, local_path): 668 warn('removing downloaded file {0} and downloading again'.format(archive_name)) 669 os.unlink(local_path) 670 else: 671 info('file {0} is already downloaded'.format(archive_name)) 672 return 673 674 downloaded = False 675 for retry in range(DOWNLOAD_RETRY_COUNT): 676 local_temp_path = local_path + '.tmp' 677 info('Downloading {} to {}'.format(archive_name, local_temp_path)) 678 try: 679 ctx = None 680 # For dl.espressif.com, add the ISRG x1 root certificate. 681 # This works around the issue with outdated certificate stores in some installations. 682 if 'dl.espressif.com' in url: 683 try: 684 ctx = ssl.create_default_context() 685 ctx.load_verify_locations(cadata=ISRG_X1_ROOT_CERT) 686 except AttributeError: 687 # no ssl.create_default_context or load_verify_locations cadata argument 688 # in Python <=2.7.8 689 pass 690 691 urlretrieve_ctx(url, local_temp_path, report_progress if not global_non_interactive else None, context=ctx) 692 sys.stdout.write('\rDone\n') 693 except Exception as e: 694 # urlretrieve could throw different exceptions, e.g. IOError when the server is down 695 # Errors are ignored because the downloaded file is checked a couple of lines later. 696 warn('Download failure {}'.format(e)) 697 sys.stdout.flush() 698 if not os.path.isfile(local_temp_path) or not self.check_download_file(download_obj, local_temp_path): 699 warn('Failed to download {} to {}'.format(url, local_temp_path)) 700 continue 701 rename_with_retry(local_temp_path, local_path) 702 downloaded = True 703 break 704 if not downloaded: 705 fatal('Failed to download, and retry count has expired') 706 raise DownloadError() 707 708 def install(self, version): 709 # Currently this is called after calling 'download' method, so here are a few asserts 710 # for the conditions which should be true once that method is done. 711 assert (version in self.versions) 712 download_obj = self.versions[version].get_download_for_platform(self._platform) 713 assert (download_obj is not None) 714 archive_name = os.path.basename(download_obj.url) 715 archive_path = os.path.join(global_idf_tools_path, 'dist', archive_name) 716 assert (os.path.isfile(archive_path)) 717 dest_dir = self.get_path_for_version(version) 718 if os.path.exists(dest_dir): 719 warn('destination path already exists, removing') 720 shutil.rmtree(dest_dir) 721 mkdir_p(dest_dir) 722 unpack(archive_path, dest_dir) 723 if self._current_options.strip_container_dirs: 724 strip_container_dirs(dest_dir, self._current_options.strip_container_dirs) 725 726 @staticmethod 727 def check_download_file(download_obj, local_path): 728 expected_sha256 = download_obj.sha256 729 expected_size = download_obj.size 730 file_size, file_sha256 = get_file_size_sha256(local_path) 731 if file_size != expected_size: 732 warn('file size mismatch for {}, expected {}, got {}'.format(local_path, expected_size, file_size)) 733 return False 734 if file_sha256 != expected_sha256: 735 warn('hash mismatch for {}, expected {}, got {}'.format(local_path, expected_sha256, file_sha256)) 736 return False 737 return True 738 739 @classmethod 740 def from_json(cls, tool_dict): 741 # json.load will return 'str' types in Python 3 and 'unicode' in Python 2 742 expected_str_type = type(u'') 743 744 # Validate json fields 745 tool_name = tool_dict.get('name') 746 if type(tool_name) is not expected_str_type: 747 raise RuntimeError('tool_name is not a string') 748 749 description = tool_dict.get('description') 750 if type(description) is not expected_str_type: 751 raise RuntimeError('description is not a string') 752 753 version_cmd = tool_dict.get('version_cmd') 754 if type(version_cmd) is not list: 755 raise RuntimeError('version_cmd for tool %s is not a list of strings' % tool_name) 756 757 version_regex = tool_dict.get('version_regex') 758 if type(version_regex) is not expected_str_type or not version_regex: 759 raise RuntimeError('version_regex for tool %s is not a non-empty string' % tool_name) 760 761 version_regex_replace = tool_dict.get('version_regex_replace') 762 if version_regex_replace and type(version_regex_replace) is not expected_str_type: 763 raise RuntimeError('version_regex_replace for tool %s is not a string' % tool_name) 764 765 export_paths = tool_dict.get('export_paths') 766 if type(export_paths) is not list: 767 raise RuntimeError('export_paths for tool %s is not a list' % tool_name) 768 769 export_vars = tool_dict.get('export_vars', {}) 770 if type(export_vars) is not dict: 771 raise RuntimeError('export_vars for tool %s is not a mapping' % tool_name) 772 773 versions = tool_dict.get('versions') 774 if type(versions) is not list: 775 raise RuntimeError('versions for tool %s is not an array' % tool_name) 776 777 install = tool_dict.get('install', False) 778 if type(install) is not expected_str_type: 779 raise RuntimeError('install for tool %s is not a string' % tool_name) 780 781 info_url = tool_dict.get('info_url', False) 782 if type(info_url) is not expected_str_type: 783 raise RuntimeError('info_url for tool %s is not a string' % tool_name) 784 785 license = tool_dict.get('license', False) 786 if type(license) is not expected_str_type: 787 raise RuntimeError('license for tool %s is not a string' % tool_name) 788 789 strip_container_dirs = tool_dict.get('strip_container_dirs', 0) 790 if strip_container_dirs and type(strip_container_dirs) is not int: 791 raise RuntimeError('strip_container_dirs for tool %s is not an int' % tool_name) 792 793 overrides_list = tool_dict.get('platform_overrides', []) 794 if type(overrides_list) is not list: 795 raise RuntimeError('platform_overrides for tool %s is not a list' % tool_name) 796 797 # Create the object 798 tool_obj = cls(tool_name, description, install, info_url, license, 799 version_cmd, version_regex, version_regex_replace, 800 strip_container_dirs) 801 802 for path in export_paths: 803 tool_obj.options.export_paths.append(path) 804 805 for name, value in export_vars.items(): 806 tool_obj.options.export_vars[name] = value 807 808 for index, override in enumerate(overrides_list): 809 platforms_list = override.get('platforms') 810 if type(platforms_list) is not list: 811 raise RuntimeError('platforms for override %d of tool %s is not a list' % (index, tool_name)) 812 813 install = override.get('install') 814 if install is not None and type(install) is not expected_str_type: 815 raise RuntimeError('install for override %d of tool %s is not a string' % (index, tool_name)) 816 817 version_cmd = override.get('version_cmd') 818 if version_cmd is not None and type(version_cmd) is not list: 819 raise RuntimeError('version_cmd for override %d of tool %s is not a list of strings' % 820 (index, tool_name)) 821 822 version_regex = override.get('version_regex') 823 if version_regex is not None and (type(version_regex) is not expected_str_type or not version_regex): 824 raise RuntimeError('version_regex for override %d of tool %s is not a non-empty string' % 825 (index, tool_name)) 826 827 version_regex_replace = override.get('version_regex_replace') 828 if version_regex_replace is not None and type(version_regex_replace) is not expected_str_type: 829 raise RuntimeError('version_regex_replace for override %d of tool %s is not a string' % 830 (index, tool_name)) 831 832 export_paths = override.get('export_paths') 833 if export_paths is not None and type(export_paths) is not list: 834 raise RuntimeError('export_paths for override %d of tool %s is not a list' % (index, tool_name)) 835 836 export_vars = override.get('export_vars') 837 if export_vars is not None and type(export_vars) is not dict: 838 raise RuntimeError('export_vars for override %d of tool %s is not a mapping' % (index, tool_name)) 839 tool_obj.platform_overrides.append(override) 840 841 recommended_versions = {} 842 for version_dict in versions: 843 version = version_dict.get('name') 844 if type(version) is not expected_str_type: 845 raise RuntimeError('version name for tool {} is not a string'.format(tool_name)) 846 847 version_status = version_dict.get('status') 848 if type(version_status) is not expected_str_type and version_status not in IDFToolVersion.STATUS_VALUES: 849 raise RuntimeError('tool {} version {} status is not one of {}', tool_name, version, 850 IDFToolVersion.STATUS_VALUES) 851 852 version_obj = IDFToolVersion(version, version_status) 853 for platform_id, platform_dict in version_dict.items(): 854 if platform_id in ['name', 'status']: 855 continue 856 if platform_id not in PLATFORM_FROM_NAME.keys(): 857 raise RuntimeError('invalid platform %s for tool %s version %s' % 858 (platform_id, tool_name, version)) 859 860 version_obj.add_download(platform_id, 861 platform_dict['url'], platform_dict['size'], platform_dict['sha256']) 862 863 if version_status == IDFToolVersion.STATUS_RECOMMENDED: 864 if platform_id not in recommended_versions: 865 recommended_versions[platform_id] = [] 866 recommended_versions[platform_id].append(version) 867 868 tool_obj.add_version(version_obj) 869 for platform_id, version_list in recommended_versions.items(): 870 if len(version_list) > 1: 871 raise RuntimeError('tool {} for platform {} has {} recommended versions'.format( 872 tool_name, platform_id, len(recommended_versions))) 873 if install != IDFTool.INSTALL_NEVER and len(recommended_versions) == 0: 874 raise RuntimeError('required/optional tool {} for platform {} has no recommended versions'.format( 875 tool_name, platform_id)) 876 877 tool_obj._update_current_options() 878 return tool_obj 879 880 def to_json(self): 881 versions_array = [] 882 for version, version_obj in self.versions.items(): 883 version_json = { 884 'name': version, 885 'status': version_obj.status 886 } 887 for platform_id, download in version_obj.downloads.items(): 888 version_json[platform_id] = { 889 'url': download.url, 890 'size': download.size, 891 'sha256': download.sha256 892 } 893 versions_array.append(version_json) 894 overrides_array = self.platform_overrides 895 896 tool_json = { 897 'name': self.name, 898 'description': self.description, 899 'export_paths': self.options.export_paths, 900 'export_vars': self.options.export_vars, 901 'install': self.options.install, 902 'info_url': self.options.info_url, 903 'license': self.options.license, 904 'version_cmd': self.options.version_cmd, 905 'version_regex': self.options.version_regex, 906 'versions': versions_array, 907 } 908 if self.options.version_regex_replace != VERSION_REGEX_REPLACE_DEFAULT: 909 tool_json['version_regex_replace'] = self.options.version_regex_replace 910 if overrides_array: 911 tool_json['platform_overrides'] = overrides_array 912 if self.options.strip_container_dirs: 913 tool_json['strip_container_dirs'] = self.options.strip_container_dirs 914 return tool_json 915 916 917def load_tools_info(): # type: () -> typing.Dict[str, IDFTool] 918 """ 919 Load tools metadata from tools.json, return a dictionary: tool name - tool info 920 """ 921 tool_versions_file_name = global_tools_json 922 923 with open(tool_versions_file_name, 'r') as f: 924 tools_info = json.load(f) 925 926 return parse_tools_info_json(tools_info) 927 928 929def parse_tools_info_json(tools_info): 930 """ 931 Parse and validate the dictionary obtained by loading the tools.json file. 932 Returns a dictionary of tools (key: tool name, value: IDFTool object). 933 """ 934 if tools_info['version'] != TOOLS_FILE_VERSION: 935 raise RuntimeError('Invalid version') 936 937 tools_dict = OrderedDict() 938 939 tools_array = tools_info.get('tools') 940 if type(tools_array) is not list: 941 raise RuntimeError('tools property is missing or not an array') 942 943 for tool_dict in tools_array: 944 tool = IDFTool.from_json(tool_dict) 945 tools_dict[tool.name] = tool 946 947 return tools_dict 948 949 950def dump_tools_json(tools_info): 951 tools_array = [] 952 for tool_name, tool_obj in tools_info.items(): 953 tool_json = tool_obj.to_json() 954 tools_array.append(tool_json) 955 file_json = {'version': TOOLS_FILE_VERSION, 'tools': tools_array} 956 return json.dumps(file_json, indent=2, separators=(',', ': '), sort_keys=True) 957 958 959def get_python_env_path(): 960 python_ver_major_minor = '{}.{}'.format(sys.version_info.major, sys.version_info.minor) 961 962 version_file_path = os.path.join(global_idf_path, 'version.txt') 963 if os.path.exists(version_file_path): 964 with open(version_file_path, 'r') as version_file: 965 idf_version_str = version_file.read() 966 else: 967 try: 968 idf_version_str = subprocess.check_output(['git', 'describe'], 969 cwd=global_idf_path, env=os.environ).decode() 970 except subprocess.CalledProcessError as e: 971 warn('Git describe was unsuccessul: {}'.format(e)) 972 idf_version_str = '' 973 match = re.match(r'^v([0-9]+\.[0-9]+).*', idf_version_str) 974 if match: 975 idf_version = match.group(1) 976 else: 977 idf_version = None 978 # fallback when IDF is a shallow clone 979 try: 980 with open(os.path.join(global_idf_path, 'components', 'esp_common', 'include', 'esp_idf_version.h')) as f: 981 m = re.search(r'^#define\s+ESP_IDF_VERSION_MAJOR\s+(\d+).+?^#define\s+ESP_IDF_VERSION_MINOR\s+(\d+)', 982 f.read(), re.DOTALL | re.MULTILINE) 983 if m: 984 idf_version = '.'.join((m.group(1), m.group(2))) 985 else: 986 warn('Reading IDF version from C header file failed!') 987 except Exception as e: 988 warn('Is it not possible to determine the IDF version: {}'.format(e)) 989 990 if idf_version is None: 991 fatal('IDF version cannot be determined') 992 raise SystemExit(1) 993 994 idf_python_env_path = os.path.join(global_idf_tools_path, 'python_env', 995 'idf{}_py{}_env'.format(idf_version, python_ver_major_minor)) 996 997 if sys.platform == 'win32': 998 subdir = 'Scripts' 999 python_exe = 'python.exe' 1000 else: 1001 subdir = 'bin' 1002 python_exe = 'python' 1003 1004 idf_python_export_path = os.path.join(idf_python_env_path, subdir) 1005 virtualenv_python = os.path.join(idf_python_export_path, python_exe) 1006 1007 return idf_python_env_path, idf_python_export_path, virtualenv_python 1008 1009 1010def action_list(args): 1011 tools_info = load_tools_info() 1012 for name, tool in tools_info.items(): 1013 if tool.get_install_type() == IDFTool.INSTALL_NEVER: 1014 continue 1015 optional_str = ' (optional)' if tool.get_install_type() == IDFTool.INSTALL_ON_REQUEST else '' 1016 info('* {}: {}{}'.format(name, tool.description, optional_str)) 1017 tool.find_installed_versions() 1018 versions_for_platform = {k: v for k, v in tool.versions.items() if v.compatible_with_platform()} 1019 if not versions_for_platform: 1020 info(' (no versions compatible with platform {})'.format(PYTHON_PLATFORM)) 1021 continue 1022 versions_sorted = sorted(versions_for_platform.keys(), key=tool.versions.get, reverse=True) 1023 for version in versions_sorted: 1024 version_obj = tool.versions[version] 1025 info(' - {} ({}{})'.format(version, version_obj.status, 1026 ', installed' if version in tool.versions_installed else '')) 1027 1028 1029def action_check(args): 1030 tools_info = load_tools_info() 1031 not_found_list = [] 1032 info('Checking for installed tools...') 1033 for name, tool in tools_info.items(): 1034 if tool.get_install_type() == IDFTool.INSTALL_NEVER: 1035 continue 1036 tool_found_somewhere = False 1037 info('Checking tool %s' % name) 1038 tool.find_installed_versions() 1039 if tool.version_in_path: 1040 info(' version found in PATH: %s' % tool.version_in_path) 1041 tool_found_somewhere = True 1042 else: 1043 info(' no version found in PATH') 1044 1045 for version in tool.versions_installed: 1046 info(' version installed in tools directory: %s' % version) 1047 tool_found_somewhere = True 1048 if not tool_found_somewhere and tool.get_install_type() == IDFTool.INSTALL_ALWAYS: 1049 not_found_list.append(name) 1050 if not_found_list: 1051 fatal('The following required tools were not found: ' + ' '.join(not_found_list)) 1052 raise SystemExit(1) 1053 1054 1055def action_export(args): 1056 tools_info = load_tools_info() 1057 all_tools_found = True 1058 export_vars = {} 1059 paths_to_export = [] 1060 for name, tool in tools_info.items(): 1061 if tool.get_install_type() == IDFTool.INSTALL_NEVER: 1062 continue 1063 tool.find_installed_versions() 1064 1065 if tool.version_in_path: 1066 if tool.version_in_path not in tool.versions: 1067 # unsupported version 1068 if args.prefer_system: 1069 warn('using an unsupported version of tool {} found in PATH: {}'.format( 1070 tool.name, tool.version_in_path)) 1071 continue 1072 else: 1073 # unsupported version in path 1074 pass 1075 else: 1076 # supported/deprecated version in PATH, use it 1077 version_obj = tool.versions[tool.version_in_path] 1078 if version_obj.status == IDFToolVersion.STATUS_SUPPORTED: 1079 info('Using a supported version of tool {} found in PATH: {}.'.format(name, tool.version_in_path), 1080 f=sys.stderr) 1081 info('However the recommended version is {}.'.format(tool.get_recommended_version()), 1082 f=sys.stderr) 1083 elif version_obj.status == IDFToolVersion.STATUS_DEPRECATED: 1084 warn('using a deprecated version of tool {} found in PATH: {}'.format(name, tool.version_in_path)) 1085 continue 1086 1087 self_restart_cmd = '{} {}{}'.format(sys.executable, __file__, 1088 (' --tools-json ' + args.tools_json) if args.tools_json else '') 1089 self_restart_cmd = to_shell_specific_paths([self_restart_cmd])[0] 1090 1091 if IDF_TOOLS_EXPORT_CMD: 1092 prefer_system_hint = '' 1093 else: 1094 prefer_system_hint = ' To use it, run \'{} export --prefer-system\''.format(self_restart_cmd) 1095 1096 if IDF_TOOLS_INSTALL_CMD: 1097 install_cmd = to_shell_specific_paths([IDF_TOOLS_INSTALL_CMD])[0] 1098 else: 1099 install_cmd = self_restart_cmd + ' install' 1100 1101 if not tool.versions_installed: 1102 if tool.get_install_type() == IDFTool.INSTALL_ALWAYS: 1103 all_tools_found = False 1104 fatal('tool {} has no installed versions. Please run \'{}\' to install it.'.format( 1105 tool.name, install_cmd)) 1106 if tool.version_in_path and tool.version_in_path not in tool.versions: 1107 info('An unsupported version of tool {} was found in PATH: {}. '.format(name, tool.version_in_path) + 1108 prefer_system_hint, f=sys.stderr) 1109 continue 1110 else: 1111 # tool is optional, and does not have versions installed 1112 # use whatever is available in PATH 1113 continue 1114 1115 if tool.version_in_path and tool.version_in_path not in tool.versions: 1116 info('Not using an unsupported version of tool {} found in PATH: {}.'.format( 1117 tool.name, tool.version_in_path) + prefer_system_hint, f=sys.stderr) 1118 1119 version_to_use = tool.get_preferred_installed_version() 1120 export_paths = tool.get_export_paths(version_to_use) 1121 if export_paths: 1122 paths_to_export += export_paths 1123 tool_export_vars = tool.get_export_vars(version_to_use) 1124 for k, v in tool_export_vars.items(): 1125 old_v = os.environ.get(k) 1126 if old_v is None or old_v != v: 1127 export_vars[k] = v 1128 1129 current_path = os.getenv('PATH') 1130 idf_python_env_path, idf_python_export_path, virtualenv_python = get_python_env_path() 1131 if os.path.exists(virtualenv_python): 1132 idf_python_env_path = to_shell_specific_paths([idf_python_env_path])[0] 1133 if os.getenv('IDF_PYTHON_ENV_PATH') != idf_python_env_path: 1134 export_vars['IDF_PYTHON_ENV_PATH'] = to_shell_specific_paths([idf_python_env_path])[0] 1135 if idf_python_export_path not in current_path: 1136 paths_to_export.append(idf_python_export_path) 1137 1138 idf_tools_dir = os.path.join(global_idf_path, 'tools') 1139 idf_tools_dir = to_shell_specific_paths([idf_tools_dir])[0] 1140 if idf_tools_dir not in current_path: 1141 paths_to_export.append(idf_tools_dir) 1142 1143 if sys.platform == 'win32' and 'MSYSTEM' not in os.environ: 1144 old_path = '%PATH%' 1145 path_sep = ';' 1146 else: 1147 old_path = '$PATH' 1148 # can't trust os.pathsep here, since for Windows Python started from MSYS shell, 1149 # os.pathsep will be ';' 1150 path_sep = ':' 1151 1152 if args.format == EXPORT_SHELL: 1153 if sys.platform == 'win32' and 'MSYSTEM' not in os.environ: 1154 export_format = 'SET "{}={}"' 1155 export_sep = '\n' 1156 else: 1157 export_format = 'export {}="{}"' 1158 export_sep = ';' 1159 elif args.format == EXPORT_KEY_VALUE: 1160 export_format = '{}={}' 1161 export_sep = '\n' 1162 else: 1163 raise NotImplementedError('unsupported export format {}'.format(args.format)) 1164 1165 if paths_to_export: 1166 export_vars['PATH'] = path_sep.join(to_shell_specific_paths(paths_to_export) + [old_path]) 1167 1168 export_statements = export_sep.join([export_format.format(k, v) for k, v in export_vars.items()]) 1169 1170 if export_statements: 1171 print(export_statements) 1172 1173 if not all_tools_found: 1174 raise SystemExit(1) 1175 1176 1177def apply_url_mirrors(args, tool_download_obj): 1178 apply_mirror_prefix_map(args, tool_download_obj) 1179 apply_github_assets_option(tool_download_obj) 1180 1181 1182def apply_mirror_prefix_map(args, tool_download_obj): 1183 """Rewrite URL for given tool_obj, given tool_version, and current platform, 1184 if --mirror-prefix-map flag or IDF_MIRROR_PREFIX_MAP environment variable is given. 1185 """ 1186 mirror_prefix_map = None 1187 mirror_prefix_map_env = os.getenv('IDF_MIRROR_PREFIX_MAP') 1188 if mirror_prefix_map_env: 1189 mirror_prefix_map = mirror_prefix_map_env.split(';') 1190 if IDF_MAINTAINER and args.mirror_prefix_map: 1191 if mirror_prefix_map: 1192 warn('Both IDF_MIRROR_PREFIX_MAP environment variable and --mirror-prefix-map flag are specified, ' + 1193 'will use the value from the command line.') 1194 mirror_prefix_map = args.mirror_prefix_map 1195 if mirror_prefix_map and tool_download_obj: 1196 for item in mirror_prefix_map: 1197 if URL_PREFIX_MAP_SEPARATOR not in item: 1198 warn('invalid mirror-prefix-map item (missing \'{}\') {}'.format(URL_PREFIX_MAP_SEPARATOR, item)) 1199 continue 1200 search, replace = item.split(URL_PREFIX_MAP_SEPARATOR, 1) 1201 old_url = tool_download_obj.url 1202 new_url = re.sub(search, replace, old_url) 1203 if new_url != old_url: 1204 info('Changed download URL: {} => {}'.format(old_url, new_url)) 1205 tool_download_obj.url = new_url 1206 break 1207 1208 1209def apply_github_assets_option(tool_download_obj): 1210 """ Rewrite URL for given tool_obj if the download URL is an https://github.com/ URL and the variable 1211 IDF_GITHUB_ASSETS is set. The github.com part of the URL will be replaced. 1212 """ 1213 try: 1214 github_assets = os.environ['IDF_GITHUB_ASSETS'].strip() 1215 except KeyError: 1216 return # no IDF_GITHUB_ASSETS 1217 if not github_assets: # variable exists but is empty 1218 return 1219 1220 # check no URL qualifier in the mirror URL 1221 if '://' in github_assets: 1222 fatal("IDF_GITHUB_ASSETS shouldn't include any URL qualifier, https:// is assumed") 1223 raise SystemExit(1) 1224 1225 # Strip any trailing / from the mirror URL 1226 github_assets = github_assets.rstrip('/') 1227 1228 old_url = tool_download_obj.url 1229 new_url = re.sub(r'^https://github.com/', 'https://{}/'.format(github_assets), old_url) 1230 if new_url != old_url: 1231 info('Using GitHub assets mirror for URL: {} => {}'.format(old_url, new_url)) 1232 tool_download_obj.url = new_url 1233 1234 1235def action_download(args): 1236 tools_info = load_tools_info() 1237 tools_spec = args.tools 1238 1239 if args.platform not in PLATFORM_FROM_NAME: 1240 fatal('unknown platform: {}' % args.platform) 1241 raise SystemExit(1) 1242 platform = PLATFORM_FROM_NAME[args.platform] 1243 1244 tools_info_for_platform = OrderedDict() 1245 for name, tool_obj in tools_info.items(): 1246 tool_for_platform = tool_obj.copy_for_platform(platform) 1247 tools_info_for_platform[name] = tool_for_platform 1248 1249 if not tools_spec or 'required' in tools_spec: 1250 tools_spec = [k for k, v in tools_info_for_platform.items() if v.get_install_type() == IDFTool.INSTALL_ALWAYS] 1251 info('Downloading tools for {}: {}'.format(platform, ', '.join(tools_spec))) 1252 elif 'all' in tools_spec: 1253 tools_spec = [k for k, v in tools_info_for_platform.items() if v.get_install_type() != IDFTool.INSTALL_NEVER] 1254 info('Downloading tools for {}: {}'.format(platform, ', '.join(tools_spec))) 1255 1256 for tool_spec in tools_spec: 1257 if '@' not in tool_spec: 1258 tool_name = tool_spec 1259 tool_version = None 1260 else: 1261 tool_name, tool_version = tool_spec.split('@', 1) 1262 if tool_name not in tools_info_for_platform: 1263 fatal('unknown tool name: {}'.format(tool_name)) 1264 raise SystemExit(1) 1265 tool_obj = tools_info_for_platform[tool_name] 1266 if tool_version is not None and tool_version not in tool_obj.versions: 1267 fatal('unknown version for tool {}: {}'.format(tool_name, tool_version)) 1268 raise SystemExit(1) 1269 if tool_version is None: 1270 tool_version = tool_obj.get_recommended_version() 1271 if tool_version is None: 1272 fatal('tool {} not found for {} platform'.format(tool_name, platform)) 1273 raise SystemExit(1) 1274 tool_spec = '{}@{}'.format(tool_name, tool_version) 1275 1276 info('Downloading {}'.format(tool_spec)) 1277 apply_url_mirrors(args, tool_obj.versions[tool_version].get_download_for_platform(platform)) 1278 1279 tool_obj.download(tool_version) 1280 1281 1282def action_install(args): 1283 tools_info = load_tools_info() 1284 tools_spec = args.tools 1285 if not tools_spec or 'required' in tools_spec: 1286 tools_spec = [k for k, v in tools_info.items() if v.get_install_type() == IDFTool.INSTALL_ALWAYS] 1287 info('Installing tools: {}'.format(', '.join(tools_spec))) 1288 elif 'all' in tools_spec: 1289 tools_spec = [k for k, v in tools_info.items() if v.get_install_type() != IDFTool.INSTALL_NEVER] 1290 info('Installing tools: {}'.format(', '.join(tools_spec))) 1291 1292 for tool_spec in tools_spec: 1293 if '@' not in tool_spec: 1294 tool_name = tool_spec 1295 tool_version = None 1296 else: 1297 tool_name, tool_version = tool_spec.split('@', 1) 1298 if tool_name not in tools_info: 1299 fatal('unknown tool name: {}'.format(tool_name)) 1300 raise SystemExit(1) 1301 tool_obj = tools_info[tool_name] 1302 if not tool_obj.compatible_with_platform(): 1303 fatal('tool {} does not have versions compatible with platform {}'.format(tool_name, CURRENT_PLATFORM)) 1304 raise SystemExit(1) 1305 if tool_version is not None and tool_version not in tool_obj.versions: 1306 fatal('unknown version for tool {}: {}'.format(tool_name, tool_version)) 1307 raise SystemExit(1) 1308 if tool_version is None: 1309 tool_version = tool_obj.get_recommended_version() 1310 assert tool_version is not None 1311 tool_obj.find_installed_versions() 1312 tool_spec = '{}@{}'.format(tool_name, tool_version) 1313 if tool_version in tool_obj.versions_installed: 1314 info('Skipping {} (already installed)'.format(tool_spec)) 1315 continue 1316 1317 info('Installing {}'.format(tool_spec)) 1318 apply_url_mirrors(args, tool_obj.versions[tool_version].get_download_for_platform(PYTHON_PLATFORM)) 1319 1320 tool_obj.download(tool_version) 1321 tool_obj.install(tool_version) 1322 1323 1324def get_wheels_dir(): 1325 tools_info = load_tools_info() 1326 wheels_package_name = 'idf-python-wheels' 1327 if wheels_package_name not in tools_info: 1328 return None 1329 wheels_package = tools_info[wheels_package_name] 1330 recommended_version = wheels_package.get_recommended_version() 1331 if recommended_version is None: 1332 return None 1333 wheels_dir = wheels_package.get_path_for_version(recommended_version) 1334 if not os.path.exists(wheels_dir): 1335 return None 1336 return wheels_dir 1337 1338 1339def action_install_python_env(args): # type: ignore 1340 reinstall = args.reinstall 1341 idf_python_env_path, _, virtualenv_python = get_python_env_path() 1342 1343 is_virtualenv = hasattr(sys, 'real_prefix') or (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix) 1344 if is_virtualenv and (not os.path.exists(idf_python_env_path) or reinstall): 1345 fatal('This script was called from a virtual environment, can not create a virtual environment again') 1346 raise SystemExit(1) 1347 1348 if os.path.exists(virtualenv_python): 1349 try: 1350 subprocess.check_call([virtualenv_python, '--version'], stdout=sys.stdout, stderr=sys.stderr) 1351 except subprocess.CalledProcessError: 1352 # At this point we can reinstall the virtual environment if it is non-functional. This can happen at least 1353 # when the Python interpreter was removed which was used to create the virtual environment. 1354 reinstall = True 1355 1356 try: 1357 subprocess.check_call([virtualenv_python, '-m', 'pip', '--version'], stdout=sys.stdout, stderr=sys.stderr) 1358 except subprocess.CalledProcessError: 1359 warn('PIP is not available in the virtual environment.') 1360 # Reinstallation of the virtual environment could help if PIP was installed for the main Python 1361 reinstall = True 1362 1363 if reinstall and os.path.exists(idf_python_env_path): 1364 warn('Removing the existing Python environment in {}'.format(idf_python_env_path)) 1365 shutil.rmtree(idf_python_env_path) 1366 1367 if not os.path.exists(virtualenv_python): 1368 info('Creating a new Python environment in {}'.format(idf_python_env_path)) 1369 1370 try: 1371 import virtualenv # noqa: F401 1372 except ImportError: 1373 info('Installing virtualenv') 1374 subprocess.check_call([sys.executable, '-m', 'pip', 'install', '--user', 'virtualenv'], 1375 stdout=sys.stdout, stderr=sys.stderr) 1376 1377 subprocess.check_call([sys.executable, '-m', 'virtualenv', idf_python_env_path], 1378 stdout=sys.stdout, stderr=sys.stderr) 1379 run_args = [virtualenv_python, '-m', 'pip', 'install', '--no-warn-script-location'] 1380 requirements_txt = os.path.join(global_idf_path, 'requirements.txt') 1381 run_args += ['-r', requirements_txt] 1382 if args.extra_wheels_dir: 1383 run_args += ['--find-links', args.extra_wheels_dir] 1384 if args.no_index: 1385 run_args += ['--no-index'] 1386 if args.extra_wheels_url: 1387 run_args += ['--extra-index-url', args.extra_wheels_url] 1388 1389 wheels_dir = get_wheels_dir() 1390 if wheels_dir is not None: 1391 run_args += ['--find-links', wheels_dir] 1392 1393 info('Installing Python packages from {}'.format(requirements_txt)) 1394 subprocess.check_call(run_args, stdout=sys.stdout, stderr=sys.stderr) 1395 1396 1397def action_add_version(args): 1398 tools_info = load_tools_info() 1399 tool_name = args.tool 1400 tool_obj = tools_info.get(tool_name) 1401 if not tool_obj: 1402 info('Creating new tool entry for {}'.format(tool_name)) 1403 tool_obj = IDFTool(tool_name, TODO_MESSAGE, IDFTool.INSTALL_ALWAYS, 1404 TODO_MESSAGE, TODO_MESSAGE, [TODO_MESSAGE], TODO_MESSAGE) 1405 tools_info[tool_name] = tool_obj 1406 version = args.version 1407 version_obj = tool_obj.versions.get(version) 1408 if version not in tool_obj.versions: 1409 info('Creating new version {}'.format(version)) 1410 version_obj = IDFToolVersion(version, IDFToolVersion.STATUS_SUPPORTED) 1411 tool_obj.versions[version] = version_obj 1412 url_prefix = args.url_prefix or 'https://%s/' % TODO_MESSAGE 1413 for file_path in args.files: 1414 file_name = os.path.basename(file_path) 1415 # Guess which platform this file is for 1416 found_platform = None 1417 for platform_alias, platform_id in PLATFORM_FROM_NAME.items(): 1418 if platform_alias in file_name: 1419 found_platform = platform_id 1420 break 1421 if found_platform is None: 1422 info('Could not guess platform for file {}'.format(file_name)) 1423 found_platform = TODO_MESSAGE 1424 # Get file size and calculate the SHA256 1425 file_size, file_sha256 = get_file_size_sha256(file_path) 1426 url = url_prefix + file_name 1427 info('Adding download for platform {}'.format(found_platform)) 1428 info(' size: {}'.format(file_size)) 1429 info(' SHA256: {}'.format(file_sha256)) 1430 info(' URL: {}'.format(url)) 1431 version_obj.add_download(found_platform, url, file_size, file_sha256) 1432 json_str = dump_tools_json(tools_info) 1433 if not args.output: 1434 args.output = os.path.join(global_idf_path, TOOLS_FILE_NEW) 1435 with open(args.output, 'w') as f: 1436 f.write(json_str) 1437 f.write('\n') 1438 info('Wrote output to {}'.format(args.output)) 1439 1440 1441def action_rewrite(args): 1442 tools_info = load_tools_info() 1443 json_str = dump_tools_json(tools_info) 1444 if not args.output: 1445 args.output = os.path.join(global_idf_path, TOOLS_FILE_NEW) 1446 with open(args.output, 'w') as f: 1447 f.write(json_str) 1448 f.write('\n') 1449 info('Wrote output to {}'.format(args.output)) 1450 1451 1452def action_validate(args): 1453 try: 1454 import jsonschema 1455 except ImportError: 1456 fatal('You need to install jsonschema package to use validate command') 1457 raise SystemExit(1) 1458 1459 with open(os.path.join(global_idf_path, TOOLS_FILE), 'r') as tools_file: 1460 tools_json = json.load(tools_file) 1461 1462 with open(os.path.join(global_idf_path, TOOLS_SCHEMA_FILE), 'r') as schema_file: 1463 schema_json = json.load(schema_file) 1464 jsonschema.validate(tools_json, schema_json) 1465 # on failure, this will raise an exception with a fairly verbose diagnostic message 1466 1467 1468def action_gen_doc(args): 1469 f = args.output 1470 tools_info = load_tools_info() 1471 1472 def print_out(text): 1473 f.write(text + '\n') 1474 1475 print_out('.. |zwsp| unicode:: U+200B') 1476 print_out(' :trim:') 1477 print_out('') 1478 1479 idf_gh_url = 'https://github.com/espressif/esp-idf' 1480 for tool_name, tool_obj in tools_info.items(): 1481 info_url = tool_obj.options.info_url 1482 if idf_gh_url + '/tree' in info_url: 1483 info_url = re.sub(idf_gh_url + r'/tree/\w+/(.*)', r':idf:`\1`', info_url) 1484 1485 license_url = 'https://spdx.org/licenses/' + tool_obj.options.license 1486 1487 print_out(""" 1488.. _tool-{name}: 1489 1490{name} 1491{underline} 1492 1493{description} 1494 1495.. include:: idf-tools-notes.inc 1496 :start-after: tool-{name}-notes 1497 :end-before: --- 1498 1499License: `{license} <{license_url}>`_ 1500 1501More info: {info_url} 1502 1503.. list-table:: 1504 :widths: 10 10 80 1505 :header-rows: 1 1506 1507 * - Platform 1508 - Required 1509 - Download 1510""".rstrip().format(name=tool_name, 1511 underline=args.heading_underline_char * len(tool_name), 1512 description=tool_obj.description, 1513 license=tool_obj.options.license, 1514 license_url=license_url, 1515 info_url=info_url)) 1516 1517 for platform_name in sorted(tool_obj.get_supported_platforms()): 1518 platform_tool = tool_obj.copy_for_platform(platform_name) 1519 install_type = platform_tool.get_install_type() 1520 if install_type == IDFTool.INSTALL_NEVER: 1521 continue 1522 elif install_type == IDFTool.INSTALL_ALWAYS: 1523 install_type_str = 'required' 1524 elif install_type == IDFTool.INSTALL_ON_REQUEST: 1525 install_type_str = 'optional' 1526 else: 1527 raise NotImplementedError() 1528 1529 version = platform_tool.get_recommended_version() 1530 version_obj = platform_tool.versions[version] 1531 download_obj = version_obj.get_download_for_platform(platform_name) 1532 1533 # Note: keep the list entries indented to the same number of columns 1534 # as the list header above. 1535 print_out(""" 1536 * - {} 1537 - {} 1538 - {} 1539 1540 .. rst-class:: tool-sha256 1541 1542 SHA256: {} 1543""".strip('\n').format(platform_name, install_type_str, download_obj.url, download_obj.sha256)) 1544 1545 print_out('') 1546 print_out('') 1547 1548 1549def main(argv): 1550 parser = argparse.ArgumentParser() 1551 1552 parser.add_argument('--quiet', help='Don\'t output diagnostic messages to stdout/stderr', action='store_true') 1553 parser.add_argument('--non-interactive', help='Don\'t output interactive messages and questions', action='store_true') 1554 parser.add_argument('--tools-json', help='Path to the tools.json file to use') 1555 parser.add_argument('--idf-path', help='ESP-IDF path to use') 1556 1557 subparsers = parser.add_subparsers(dest='action') 1558 subparsers.add_parser('list', help='List tools and versions available') 1559 subparsers.add_parser('check', help='Print summary of tools installed or found in PATH') 1560 export = subparsers.add_parser('export', help='Output command for setting tool paths, suitable for shell') 1561 export.add_argument('--format', choices=[EXPORT_SHELL, EXPORT_KEY_VALUE], default=EXPORT_SHELL, 1562 help='Format of the output: shell (suitable for printing into shell), ' + 1563 'or key-value (suitable for parsing by other tools') 1564 export.add_argument('--prefer-system', help='Normally, if the tool is already present in PATH, ' + 1565 'but has an unsupported version, a version from the tools directory ' + 1566 'will be used instead. If this flag is given, the version in PATH ' + 1567 'will be used.', action='store_true') 1568 install = subparsers.add_parser('install', help='Download and install tools into the tools directory') 1569 install.add_argument('tools', metavar='TOOL', nargs='*', default=['required'], 1570 help='Tools to install. ' + 1571 'To install a specific version use <tool_name>@<version> syntax. ' + 1572 'Use empty or \'required\' to install required tools, not optional ones. ' + 1573 'Use \'all\' to install all tools, including the optional ones.') 1574 1575 download = subparsers.add_parser('download', help='Download the tools into the dist directory') 1576 download.add_argument('--platform', help='Platform to download the tools for') 1577 download.add_argument('tools', metavar='TOOL', nargs='*', default=['required'], 1578 help='Tools to download. ' + 1579 'To download a specific version use <tool_name>@<version> syntax. ' + 1580 'Use empty or \'required\' to download required tools, not optional ones. ' + 1581 'Use \'all\' to download all tools, including the optional ones.') 1582 1583 if IDF_MAINTAINER: 1584 for subparser in [download, install]: 1585 subparser.add_argument('--mirror-prefix-map', nargs='*', 1586 help='Pattern to rewrite download URLs, with source and replacement separated by comma.' + 1587 ' E.g. http://foo.com,http://test.foo.com') 1588 1589 install_python_env = subparsers.add_parser('install-python-env', 1590 help='Create Python virtual environment and install the ' + 1591 'required Python packages') 1592 install_python_env.add_argument('--reinstall', help='Discard the previously installed environment', 1593 action='store_true') 1594 install_python_env.add_argument('--extra-wheels-dir', help='Additional directories with wheels ' + 1595 'to use during installation') 1596 install_python_env.add_argument('--extra-wheels-url', help='Additional URL with wheels', default='https://dl.espressif.com/pypi') 1597 install_python_env.add_argument('--no-index', help='Work offline without retrieving wheels index') 1598 1599 if IDF_MAINTAINER: 1600 add_version = subparsers.add_parser('add-version', help='Add or update download info for a version') 1601 add_version.add_argument('--output', help='Save new tools.json into this file') 1602 add_version.add_argument('--tool', help='Tool name to set add a version for', required=True) 1603 add_version.add_argument('--version', help='Version identifier', required=True) 1604 add_version.add_argument('--url-prefix', help='String to prepend to file names to obtain download URLs') 1605 add_version.add_argument('files', help='File names of the download artifacts', nargs='*') 1606 1607 rewrite = subparsers.add_parser('rewrite', help='Load tools.json, validate, and save the result back into JSON') 1608 rewrite.add_argument('--output', help='Save new tools.json into this file') 1609 1610 subparsers.add_parser('validate', help='Validate tools.json against schema file') 1611 1612 gen_doc = subparsers.add_parser('gen-doc', help='Write the list of tools as a documentation page') 1613 gen_doc.add_argument('--output', type=argparse.FileType('w'), default=sys.stdout, 1614 help='Output file name') 1615 gen_doc.add_argument('--heading-underline-char', help='Character to use when generating RST sections', default='~') 1616 1617 args = parser.parse_args(argv) 1618 1619 if args.action is None: 1620 parser.print_help() 1621 parser.exit(1) 1622 1623 if args.quiet: 1624 global global_quiet 1625 global_quiet = True 1626 1627 if args.non_interactive: 1628 global global_non_interactive 1629 global_non_interactive = True 1630 1631 global global_idf_path 1632 global_idf_path = os.environ.get('IDF_PATH') 1633 if args.idf_path: 1634 global_idf_path = args.idf_path 1635 if not global_idf_path: 1636 global_idf_path = os.path.realpath(os.path.join(os.path.dirname(__file__), '..')) 1637 os.environ['IDF_PATH'] = global_idf_path 1638 1639 global global_idf_tools_path 1640 global_idf_tools_path = os.environ.get('IDF_TOOLS_PATH') or os.path.expanduser(IDF_TOOLS_PATH_DEFAULT) 1641 1642 # On macOS, unset __PYVENV_LAUNCHER__ variable if it is set. 1643 # Otherwise sys.executable keeps pointing to the system Python, even when a python binary from a virtualenv is invoked. 1644 # See https://bugs.python.org/issue22490#msg283859. 1645 os.environ.pop('__PYVENV_LAUNCHER__', None) 1646 1647 if sys.version_info.major == 2: 1648 try: 1649 global_idf_tools_path.decode('ascii') 1650 except UnicodeDecodeError: 1651 fatal('IDF_TOOLS_PATH contains non-ASCII characters: {}'.format(global_idf_tools_path) + 1652 '\nThis is not supported yet with Python 2. ' + 1653 'Please set IDF_TOOLS_PATH to a directory with an ASCII name, or switch to Python 3.') 1654 raise SystemExit(1) 1655 1656 if CURRENT_PLATFORM == UNKNOWN_PLATFORM: 1657 fatal('Platform {} appears to be unsupported'.format(PYTHON_PLATFORM)) 1658 raise SystemExit(1) 1659 1660 global global_tools_json 1661 if args.tools_json: 1662 global_tools_json = args.tools_json 1663 else: 1664 global_tools_json = os.path.join(global_idf_path, TOOLS_FILE) 1665 1666 action_func_name = 'action_' + args.action.replace('-', '_') 1667 action_func = globals()[action_func_name] 1668 1669 action_func(args) 1670 1671 1672if __name__ == '__main__': 1673 main(sys.argv[1:]) 1674