#!/usr/bin/env python3

# Copyright The Mbed TLS Contributors
# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later

"""
Test Mbed TLS with a subset of algorithms.

This script can be divided into several steps:

First, include/mbedtls/config.h or a different config file passed
in the arguments is parsed to extract any configuration options (using config.py).

Then, test domains (groups of jobs, tests) are built based on predefined data
collected in the DomainData class. Here, each domain has five major traits:
- domain name, can be used to run only specific tests via command-line;
- configuration building method, described in detail below;
- list of symbols passed to the configuration building method;
- commands to be run on each job (only build, build and test, or any other custom);
- optional list of symbols to be excluded from testing.

The configuration building method can be one of the three following:

- ComplementaryDomain - build a job for each passed symbol by disabling a single
  symbol and its reverse dependencies (defined in REVERSE_DEPENDENCIES);

- ExclusiveDomain - build a job where, for each passed symbol, only this particular
  one is defined and other symbols from the list are unset. For each job look for
  any non-standard symbols to set/unset in EXCLUSIVE_GROUPS. These are usually not
  direct dependencies, but rather non-trivial results of other configs missing. Then
  look for any unset symbols and handle their reverse dependencies.

- DualDomain - combination of the two above - both complementary and exclusive domain
  job generation code will be run. Currently only used for hashes.

Lastly, the collected jobs are executed and (optionally) tested, with
error reporting and coloring as configured in options. Each test starts with
a full config without a couple of slowing down or unnecessary options
(see set_reference_config), then the specific job config is derived.
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
import traceback
from typing import Union

# Add the Mbed TLS Python library directory to the module search path
import scripts_path # pylint: disable=unused-import
import config

class Colors: # pylint: disable=too-few-public-methods
    """Minimalistic support for colored output.
Each field of an object of this class is either None if colored output
is not possible or not desired, or a pair of strings (start, stop) such
that outputting start switches the text color to the desired color and
stop switches the text color back to the default."""
    red = None
    green = None
    cyan = None
    bold_red = None
    bold_green = None
    def __init__(self, options=None):
        """Initialize color profile according to passed options."""
        if not options or options.color in ['no', 'never']:
            want_color = False
        elif options.color in ['yes', 'always']:
            want_color = True
        else:
            want_color = sys.stderr.isatty()
        if want_color:
            # Assume ANSI compatible terminal
            normal = '\033[0m'
            self.red = ('\033[31m', normal)
            self.green = ('\033[32m', normal)
            self.cyan = ('\033[36m', normal)
            self.bold_red = ('\033[1;31m', normal)
            self.bold_green = ('\033[1;32m', normal)
NO_COLORS = Colors(None)

def log_line(text, prefix='depends.py:', suffix='', color=None):
    """Print a status message."""
    if color is not None:
        prefix = color[0] + prefix
        suffix = suffix + color[1]
    sys.stderr.write(prefix + ' ' + text + suffix + '\n')
    sys.stderr.flush()

def log_command(cmd):
    """Print a trace of the specified command.
cmd is a list of strings: a command name and its arguments."""
    log_line(' '.join(cmd), prefix='+')

def backup_config(options):
    """Back up the library configuration file (config.h).
If the backup file already exists, it is presumed to be the desired backup,
so don't make another backup."""
    if os.path.exists(options.config_backup):
        options.own_backup = False
    else:
        options.own_backup = True
        shutil.copy(options.config, options.config_backup)

def restore_config(options):
    """Restore the library configuration file (config.h).
Remove the backup file if it was saved earlier."""
    if options.own_backup:
        shutil.move(options.config_backup, options.config)
    else:
        shutil.copy(options.config_backup, options.config)

def option_exists(conf, option):
    return option in conf.settings

def set_config_option_value(conf, option, colors, value: Union[bool, str]):
    """Set/unset a configuration option, optionally specifying a value.
value can be either True/False (set/unset config option), or a string,
which will make a symbol defined with a certain value."""
    if not option_exists(conf, option):
        log_line('Symbol {} was not found in {}'.format(option, conf.filename), color=colors.red)
        return False

    if value is False:
        log_command(['config.py', 'unset', option])
        conf.unset(option)
    elif value is True:
        log_command(['config.py', 'set', option])
        conf.set(option)
    else:
        log_command(['config.py', 'set', option, value])
        conf.set(option, value)
    return True

def set_reference_config(conf, options, colors):
    """Change the library configuration file (config.h) to the reference state.
The reference state is the one from which the tested configurations are
derived."""
    # Turn off options that are not relevant to the tests and slow them down.
    log_command(['config.py', 'full'])
    conf.adapt(config.full_adapter)
    set_config_option_value(conf, 'MBEDTLS_TEST_HOOKS', colors, False)
    if options.unset_use_psa:
        set_config_option_value(conf, 'MBEDTLS_USE_PSA_CRYPTO', colors, False)

class Job:
    """A job builds the library in a specific configuration and runs some tests."""
    def __init__(self, name, config_settings, commands):
        """Build a job object.
The job uses the configuration described by config_settings. This is a
dictionary where the keys are preprocessor symbols and the values are
booleans or strings. A boolean indicates whether or not to #define the
symbol. With a string, the symbol is #define'd to that value.
After setting the configuration, the job runs the programs specified by
commands. This is a list of lists of strings; each list of string is a
command name and its arguments and is passed to subprocess.call with
shell=False."""
        self.name = name
        self.config_settings = config_settings
        self.commands = commands

    def announce(self, colors, what):
        '''Announce the start or completion of a job.
If what is None, announce the start of the job.
If what is True, announce that the job has passed.
If what is False, announce that the job has failed.'''
        if what is True:
            log_line(self.name + ' PASSED', color=colors.green)
        elif what is False:
            log_line(self.name + ' FAILED', color=colors.red)
        else:
            log_line('starting ' + self.name, color=colors.cyan)

    def configure(self, conf, options, colors):
        '''Set library configuration options as required for the job.'''
        set_reference_config(conf, options, colors)
        for key, value in sorted(self.config_settings.items()):
            ret = set_config_option_value(conf, key, colors, value)
            if ret is False:
                return False
        return True

    def test(self, options):
        '''Run the job's build and test commands.
Return True if all the commands succeed and False otherwise.
If options.keep_going is false, stop as soon as one command fails. Otherwise
run all the commands, except that if the first command fails, none of the
other commands are run (typically, the first command is a build command
and subsequent commands are tests that cannot run if the build failed).'''
        built = False
        success = True
        for command in self.commands:
            log_command(command)
            ret = subprocess.call(command)
            if ret != 0:
                if command[0] not in ['make', options.make_command]:
                    log_line('*** [{}] Error {}'.format(' '.join(command), ret))
                if not options.keep_going or not built:
                    return False
                success = False
            built = True
        return success

# SSL/TLS versions up to 1.1 and corresponding options. These require
# both MD5 and SHA-1.
SSL_PRE_1_2_DEPENDENCIES = ['MBEDTLS_SSL_CBC_RECORD_SPLITTING',
                            'MBEDTLS_SSL_PROTO_SSL3',
                            'MBEDTLS_SSL_PROTO_TLS1',
                            'MBEDTLS_SSL_PROTO_TLS1_1']

# If the configuration option A requires B, make sure that
# B in REVERSE_DEPENDENCIES[A].
# All the information here should be contained in check_config.h. This
# file includes a copy because it changes rarely and it would be a pain
# to extract automatically.
REVERSE_DEPENDENCIES = {
    'MBEDTLS_AES_C': ['MBEDTLS_CTR_DRBG_C',
                      'MBEDTLS_NIST_KW_C'],
    'MBEDTLS_CHACHA20_C': ['MBEDTLS_CHACHAPOLY_C'],
    'MBEDTLS_ECDSA_C': ['MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA_ENABLED',
                        'MBEDTLS_KEY_EXCHANGE_ECDH_ECDSA_ENABLED'],
    'MBEDTLS_ECP_C': ['MBEDTLS_ECDSA_C',
                      'MBEDTLS_ECDH_C',
                      'MBEDTLS_ECJPAKE_C',
                      'MBEDTLS_ECP_RESTARTABLE',
                      'MBEDTLS_KEY_EXCHANGE_ECDH_ECDSA_ENABLED',
                      'MBEDTLS_KEY_EXCHANGE_ECDH_RSA_ENABLED',
                      'MBEDTLS_KEY_EXCHANGE_ECDHE_PSK_ENABLED',
                      'MBEDTLS_KEY_EXCHANGE_ECDHE_RSA_ENABLED',
                      'MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA_ENABLED',
                      'MBEDTLS_KEY_EXCHANGE_ECJPAKE_ENABLED'],
    'MBEDTLS_ECP_DP_SECP256R1_ENABLED': ['MBEDTLS_KEY_EXCHANGE_ECJPAKE_ENABLED'],
    'MBEDTLS_MD5_C': SSL_PRE_1_2_DEPENDENCIES,
    'MBEDTLS_PKCS1_V21': ['MBEDTLS_X509_RSASSA_PSS_SUPPORT'],
    'MBEDTLS_PKCS1_V15': ['MBEDTLS_KEY_EXCHANGE_DHE_RSA_ENABLED',
                          'MBEDTLS_KEY_EXCHANGE_ECDHE_RSA_ENABLED',
                          'MBEDTLS_KEY_EXCHANGE_RSA_PSK_ENABLED',
                          'MBEDTLS_KEY_EXCHANGE_RSA_ENABLED'],
    'MBEDTLS_RSA_C': ['MBEDTLS_X509_RSASSA_PSS_SUPPORT',
                      'MBEDTLS_KEY_EXCHANGE_DHE_RSA_ENABLED',
                      'MBEDTLS_KEY_EXCHANGE_ECDHE_RSA_ENABLED',
                      'MBEDTLS_KEY_EXCHANGE_RSA_PSK_ENABLED',
                      'MBEDTLS_KEY_EXCHANGE_RSA_ENABLED',
                      'MBEDTLS_KEY_EXCHANGE_ECDH_RSA_ENABLED'],
    'MBEDTLS_SHA1_C': SSL_PRE_1_2_DEPENDENCIES,
    'MBEDTLS_SHA256_C': ['MBEDTLS_KEY_EXCHANGE_ECJPAKE_ENABLED',
                         'MBEDTLS_ENTROPY_FORCE_SHA256'],
    'MBEDTLS_X509_RSASSA_PSS_SUPPORT': []
}

# If an option is tested in an exclusive test, alter the following defines.
# These are not necessarily dependencies, but just minimal required changes
# if a given define is the only one enabled from an exclusive group.
EXCLUSIVE_GROUPS = {
    'MBEDTLS_SHA512_C': ['-MBEDTLS_SSL_PROTO_TLS1_3_EXPERIMENTAL'],
    'MBEDTLS_SHA512_NO_SHA384': ['+MBEDTLS_SHA512_C',
                                 '-MBEDTLS_SSL_PROTO_TLS1_2',
                                 '-MBEDTLS_SSL_PROTO_DTLS',
                                 '-MBEDTLS_SSL_TLS_C',
                                 '-MBEDTLS_SSL_CLI_C',
                                 '-MBEDTLS_SSL_SRV_C',
                                 '-MBEDTLS_SSL_DTLS_HELLO_VERIFY',
                                 '-MBEDTLS_SSL_DTLS_ANTI_REPLAY',
                                 '-MBEDTLS_SSL_DTLS_CONNECTION_ID',
                                 '-MBEDTLS_SSL_DTLS_BADMAC_LIMIT',
                                 '-MBEDTLS_SSL_ENCRYPT_THEN_MAC',
                                 '-MBEDTLS_SSL_EXTENDED_MASTER_SECRET',
                                 '-MBEDTLS_SSL_DTLS_SRTP',
                                 '-MBEDTLS_SSL_DTLS_CLIENT_PORT_REUSE'],
    'MBEDTLS_ECP_DP_CURVE448_ENABLED': ['-MBEDTLS_ECDSA_C',
                                        '-MBEDTLS_ECDSA_DETERMINISTIC',
                                        '-MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA_ENABLED',
                                        '-MBEDTLS_KEY_EXCHANGE_ECDH_ECDSA_ENABLED',
                                        '-MBEDTLS_ECJPAKE_C',
                                        '-MBEDTLS_KEY_EXCHANGE_ECJPAKE_ENABLED'],
    'MBEDTLS_ECP_DP_CURVE25519_ENABLED': ['-MBEDTLS_ECDSA_C',
                                          '-MBEDTLS_ECDSA_DETERMINISTIC',
                                          '-MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA_ENABLED',
                                          '-MBEDTLS_KEY_EXCHANGE_ECDH_ECDSA_ENABLED',
                                          '-MBEDTLS_ECJPAKE_C',
                                          '-MBEDTLS_KEY_EXCHANGE_ECJPAKE_ENABLED'],
    'MBEDTLS_ARIA_C': ['-MBEDTLS_CMAC_C'],
    'MBEDTLS_ARC4_C': ['-MBEDTLS_CMAC_C',
                       '-MBEDTLS_CCM_C',
                       '-MBEDTLS_SSL_TICKET_C',
                       '-MBEDTLS_SSL_CONTEXT_SERIALIZATION',
                       '-MBEDTLS_GCM_C'],
    'MBEDTLS_BLOWFISH_C': ['-MBEDTLS_CMAC_C',
                           '-MBEDTLS_CCM_C',
                           '-MBEDTLS_SSL_TICKET_C',
                           '-MBEDTLS_SSL_CONTEXT_SERIALIZATION',
                           '-MBEDTLS_GCM_C'],
    'MBEDTLS_CAMELLIA_C': ['-MBEDTLS_CMAC_C'],
    'MBEDTLS_CHACHA20_C': ['-MBEDTLS_CMAC_C', '-MBEDTLS_CCM_C', '-MBEDTLS_GCM_C'],
    'MBEDTLS_DES_C': ['-MBEDTLS_CCM_C',
                      '-MBEDTLS_GCM_C',
                      '-MBEDTLS_SSL_TICKET_C',
                      '-MBEDTLS_SSL_CONTEXT_SERIALIZATION'],
}
def handle_exclusive_groups(config_settings, symbol):
    """For every symbol tested in an exclusive group check if there are other
defines to be altered. """
    for dep in EXCLUSIVE_GROUPS.get(symbol, []):
        unset = dep.startswith('-')
        dep = dep[1:]
        config_settings[dep] = not unset

def turn_off_dependencies(config_settings):
    """For every option turned off config_settings, also turn off what depends on it.
An option O is turned off if config_settings[O] is False."""
    for key, value in sorted(config_settings.items()):
        if value is not False:
            continue
        for dep in REVERSE_DEPENDENCIES.get(key, []):
            config_settings[dep] = False

class BaseDomain: # pylint: disable=too-few-public-methods, unused-argument
    """A base class for all domains."""
    def __init__(self, symbols, commands, exclude):
        """Initialize the jobs container"""
        self.jobs = []

class ExclusiveDomain(BaseDomain): # pylint: disable=too-few-public-methods
    """A domain consisting of a set of conceptually-equivalent settings.
Establish a list of configuration symbols. For each symbol, run a test job
with this symbol set and the others unset."""
    def __init__(self, symbols, commands, exclude=None):
        """Build a domain for the specified list of configuration symbols.
The domain contains a set of jobs that enable one of the elements
of symbols and disable the others.
Each job runs the specified commands.
If exclude is a regular expression, skip generated jobs whose description
would match this regular expression."""
        super().__init__(symbols, commands, exclude)
        base_config_settings = {}
        for symbol in symbols:
            base_config_settings[symbol] = False
        for symbol in symbols:
            description = symbol
            if exclude and re.match(exclude, description):
                continue
            config_settings = base_config_settings.copy()
            config_settings[symbol] = True
            handle_exclusive_groups(config_settings, symbol)
            turn_off_dependencies(config_settings)
            job = Job(description, config_settings, commands)
            self.jobs.append(job)

class ComplementaryDomain(BaseDomain): # pylint: disable=too-few-public-methods
    """A domain consisting of a set of loosely-related settings.
Establish a list of configuration symbols. For each symbol, run a test job
with this symbol unset.
If exclude is a regular expression, skip generated jobs whose description
would match this regular expression."""
    def __init__(self, symbols, commands, exclude=None):
        """Build a domain for the specified list of configuration symbols.
Each job in the domain disables one of the specified symbols.
Each job runs the specified commands."""
        super().__init__(symbols, commands, exclude)
        for symbol in symbols:
            description = '!' + symbol
            if exclude and re.match(exclude, description):
                continue
            config_settings = {symbol: False}
            turn_off_dependencies(config_settings)
            job = Job(description, config_settings, commands)
            self.jobs.append(job)

class DualDomain(ExclusiveDomain, ComplementaryDomain): # pylint: disable=too-few-public-methods
    """A domain that contains both the ExclusiveDomain and BaseDomain tests.
Both parent class __init__ calls are performed in any order and
each call adds respective jobs. The job array initialization is done once in
BaseDomain, before the parent __init__ calls."""

class CipherInfo: # pylint: disable=too-few-public-methods
    """Collect data about cipher.h."""
    def __init__(self):
        self.base_symbols = set()
        with open('include/mbedtls/cipher.h', encoding="utf-8") as fh:
            for line in fh:
                m = re.match(r' *MBEDTLS_CIPHER_ID_(\w+),', line)
                if m and m.group(1) not in ['NONE', 'NULL', '3DES']:
                    self.base_symbols.add('MBEDTLS_' + m.group(1) + '_C')

class DomainData:
    """A container for domains and jobs, used to structurize testing."""
    def config_symbols_matching(self, regexp):
        """List the config.h settings matching regexp."""
        return [symbol for symbol in self.all_config_symbols
                if re.match(regexp, symbol)]

    def __init__(self, options, conf):
        """Gather data about the library and establish a list of domains to test."""
        build_command = [options.make_command, 'CFLAGS=-Werror']
        build_and_test = [build_command, [options.make_command, 'test']]
        self.all_config_symbols = set(conf.settings.keys())
        # Find hash modules by name.
        hash_symbols = self.config_symbols_matching(r'MBEDTLS_(MD|RIPEMD|SHA)[0-9]+_C\Z')
        hash_symbols.append("MBEDTLS_SHA512_NO_SHA384")
        # Find elliptic curve enabling macros by name.
        curve_symbols = self.config_symbols_matching(r'MBEDTLS_ECP_DP_\w+_ENABLED\Z')
        # Find key exchange enabling macros by name.
        key_exchange_symbols = self.config_symbols_matching(r'MBEDTLS_KEY_EXCHANGE_\w+_ENABLED\Z')
        # Find cipher IDs (block permutations and stream ciphers --- chaining
        # and padding modes are exercised separately) information by parsing
        # cipher.h, as the information is not readily available in config.h.
        cipher_info = CipherInfo()
        # Find block cipher chaining and padding mode enabling macros by name.
        cipher_chaining_symbols = self.config_symbols_matching(r'MBEDTLS_CIPHER_MODE_\w+\Z')
        cipher_padding_symbols = self.config_symbols_matching(r'MBEDTLS_CIPHER_PADDING_\w+\Z')
        self.domains = {
            # Cipher IDs, chaining modes and padding modes. Run the test suites.
            'cipher_id': ExclusiveDomain(cipher_info.base_symbols,
                                         build_and_test),
            'cipher_chaining': ExclusiveDomain(cipher_chaining_symbols,
                                               build_and_test),
            'cipher_padding': ExclusiveDomain(cipher_padding_symbols,
                                              build_and_test),
            # Elliptic curves. Run the test suites.
            'curves': ExclusiveDomain(curve_symbols, build_and_test),
            # Hash algorithms. Exclude exclusive domain of MD, RIPEMD, SHA1 (obsolete)
            'hashes': DualDomain(hash_symbols, build_and_test,
                                 exclude=r'MBEDTLS_(MD|RIPEMD|SHA1_)'\
                                          '|!MBEDTLS_*_NO_SHA'),
            # Key exchange types.
            'kex': ExclusiveDomain(key_exchange_symbols, build_and_test),
            'pkalgs': ComplementaryDomain(['MBEDTLS_ECDSA_C',
                                           'MBEDTLS_ECP_C',
                                           'MBEDTLS_PKCS1_V21',
                                           'MBEDTLS_PKCS1_V15',
                                           'MBEDTLS_RSA_C',
                                           'MBEDTLS_X509_RSASSA_PSS_SUPPORT'],
                                          build_and_test),
        }
        self.jobs = {}
        for domain in self.domains.values():
            for job in domain.jobs:
                self.jobs[job.name] = job

    def get_jobs(self, name):
        """Return the list of jobs identified by the given name.
A name can either be the name of a domain or the name of one specific job."""
        if name in self.domains:
            return sorted(self.domains[name].jobs, key=lambda job: job.name)
        else:
            return [self.jobs[name]]

def run(options, job, conf, colors=NO_COLORS):
    """Run the specified job (a Job instance)."""
    subprocess.check_call([options.make_command, 'clean'])
    job.announce(colors, None)
    if not job.configure(conf, options, colors):
        job.announce(colors, False)
        return False
    conf.write()
    success = job.test(options)
    job.announce(colors, success)
    return success

def run_tests(options, domain_data, conf):
    """Run the desired jobs.
domain_data should be a DomainData instance that describes the available
domains and jobs.
Run the jobs listed in options.tasks."""
    if not hasattr(options, 'config_backup'):
        options.config_backup = options.config + '.bak'
    colors = Colors(options)
    jobs = []
    failures = []
    successes = []
    for name in options.tasks:
        jobs += domain_data.get_jobs(name)
    backup_config(options)
    try:
        for job in jobs:
            success = run(options, job, conf, colors=colors)
            if not success:
                if options.keep_going:
                    failures.append(job.name)
                else:
                    return False
            else:
                successes.append(job.name)
        restore_config(options)
    except:
        # Restore the configuration, except in stop-on-error mode if there
        # was an error, where we leave the failing configuration up for
        # developer convenience.
        if options.keep_going:
            restore_config(options)
        raise
    if successes:
        log_line('{} passed'.format(' '.join(successes)), color=colors.bold_green)
    if failures:
        log_line('{} FAILED'.format(' '.join(failures)), color=colors.bold_red)
        return False
    else:
        return True

def main():
    try:
        parser = argparse.ArgumentParser(
            formatter_class=argparse.RawDescriptionHelpFormatter,
            description=
            "Test Mbed TLS with a subset of algorithms.\n\n"
            "Example usage:\n"
            r"./tests/scripts/depends.py \!MBEDTLS_SHA1_C MBEDTLS_SHA256_C""\n"
            "./tests/scripts/depends.py MBEDTLS_AES_C hashes\n"
            "./tests/scripts/depends.py cipher_id cipher_chaining\n")
        parser.add_argument('--color', metavar='WHEN',
                            help='Colorize the output (always/auto/never)',
                            choices=['always', 'auto', 'never'], default='auto')
        parser.add_argument('-c', '--config', metavar='FILE',
                            help='Configuration file to modify',
                            default='include/mbedtls/config.h')
        parser.add_argument('-C', '--directory', metavar='DIR',
                            help='Change to this directory before anything else',
                            default='.')
        parser.add_argument('-k', '--keep-going',
                            help='Try all configurations even if some fail (default)',
                            action='store_true', dest='keep_going', default=True)
        parser.add_argument('-e', '--no-keep-going',
                            help='Stop as soon as a configuration fails',
                            action='store_false', dest='keep_going')
        parser.add_argument('--list-jobs',
                            help='List supported jobs and exit',
                            action='append_const', dest='list', const='jobs')
        parser.add_argument('--list-domains',
                            help='List supported domains and exit',
                            action='append_const', dest='list', const='domains')
        parser.add_argument('--make-command', metavar='CMD',
                            help='Command to run instead of make (e.g. gmake)',
                            action='store', default='make')
        parser.add_argument('--unset-use-psa',
                            help='Unset MBEDTLS_USE_PSA_CRYPTO before any test',
                            action='store_true', dest='unset_use_psa')
        parser.add_argument('tasks', metavar='TASKS', nargs='*',
                            help='The domain(s) or job(s) to test (default: all).',
                            default=True)
        options = parser.parse_args()
        os.chdir(options.directory)
        conf = config.ConfigFile(options.config)
        domain_data = DomainData(options, conf)

        if options.tasks is True:
            options.tasks = sorted(domain_data.domains.keys())
        if options.list:
            for arg in options.list:
                for domain_name in sorted(getattr(domain_data, arg).keys()):
                    print(domain_name)
            sys.exit(0)
        else:
            sys.exit(0 if run_tests(options, domain_data, conf) else 1)
    except Exception: # pylint: disable=broad-except
        traceback.print_exc()
        sys.exit(3)

if __name__ == '__main__':
    main()
