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