From 94a0eb27fb6833b96462b13b20bd6255b070c9ac Mon Sep 17 00:00:00 2001 From: Johan Lorenzo Date: Fri, 10 Mar 2017 11:33:31 +0100 Subject: [PATCH 1/4] get_apk: download() doesn't rename files anymore --- mozapkpublisher/get_apk.py | 160 ++++++++++++++------------- mozapkpublisher/test/test_get_apk.py | 40 +++---- 2 files changed, 102 insertions(+), 98 deletions(-) diff --git a/mozapkpublisher/get_apk.py b/mozapkpublisher/get_apk.py index 07543810..3ebd3750 100755 --- a/mozapkpublisher/get_apk.py +++ b/mozapkpublisher/get_apk.py @@ -13,6 +13,8 @@ logger = logging.getLogger(__name__) +FTP_BASE_URL = 'https://ftp.mozilla.org/pub/mobile' + class GetAPK(Base): arch_values = ["arm", "x86"] @@ -21,11 +23,6 @@ class GetAPK(Base): download_dir = "apk-download" - apk_ext = ".apk" - checksums_ext = ".checksums" - android_prefix = "android-" - - base_url = "https://ftp.mozilla.org/pub/mobile" json_version_url = "https://product-details.mozilla.org/1.0/firefox_versions.json" def __init__(self, config=None): @@ -34,7 +31,7 @@ def __init__(self, config=None): @classmethod def _init_parser(cls): cls.parser = ArgumentParser( - description='Download the apk of Firefox for Android from {}'.format(cls.base_url) + description='Download APKs of Firefox for Android (aka Fennec) from {}'.format(FTP_BASE_URL) ) exclusive_group = cls.parser.add_mutually_exclusive_group(required=True) @@ -66,83 +63,36 @@ def cleanup(self): except OSError: # XXX: Used for compatibility with Python 2. Use FileNotFoundError otherwise logger.warn('{} was not found. Skipping...'.format(self.download_dir)) - def _fetch_checksum_from_file(self, checksum_file, apk_file): - base_apk_filename = os.path.basename(apk_file) - with open(checksum_file, 'r') as fh: - for line in fh: - m = re.match(r"""^(?P.*) sha512 (?P\d+) {}""".format(base_apk_filename), line) - if m: - gd = m.groupdict() - logger.info("Found hash {}".format(gd['hash'])) - return gd['hash'] - # old style pre-53 checksums files - with open(checksum_file, 'r') as f: - checksum = f.read() - checksum = re.sub("\s(.*)", "", checksum.splitlines()[0]) - logger.info("Found hash {}".format(checksum)) - return checksum - - def check_apk(self, apk_file, checksum_file): - logger.debug('Checking checksum for "{}"...'.format(apk_file)) - - checksum = self._fetch_checksum_from_file(checksum_file, apk_file) - apk_checksum = file_sha512sum(apk_file) - - if checksum == apk_checksum: - logger.info('Checksum for "{}" succeeded!'.format(apk_file)) - os.remove(checksum_file) - else: - shutil.rmtree(self.download_dir) - raise CheckSumMismatch(apk_file, expected=apk_checksum, actual=checksum) - - # Helper functions - def generate_url(self, version, build, locale, api_suffix, arch_file): + def generate_apk_base_url(self, version, build, locale, api_suffix): if self.config.latest_nightly or self.config.latest_aurora: - code = "central" if self.config.latest_nightly else "aurora" - return '{}/nightly/latest-mozilla-{}-android-{}/fennec-{}.{}.android-{}'.format( - self.base_url, code, api_suffix, version, locale, arch_file + repository = "central" if self.config.latest_nightly else "aurora" + return '{}/nightly/latest-mozilla-{}-android-{}'.format( + FTP_BASE_URL, repository, api_suffix, ) - return '{}/candidates/{}-candidates/build{}/{}{}/{}/fennec-{}.{}.{}{}'.format( - self.base_url, version, build, self.android_prefix, api_suffix, locale, version, locale, - self.android_prefix, arch_file + return '{}/candidates/{}-candidates/build{}/android-{}/{}'.format( + FTP_BASE_URL, version, build, api_suffix, locale, ) def get_api_suffix(self, arch): return self.multi_apis if arch in self.multi_api_archs else [arch] - def get_arch_file(self, arch): - # the filename contains i386 instead of x86 - return 'i386' if arch == 'x86' else arch - - def get_common_file_name(self, version, locale): - return 'fennec-{}.{}.{}'.format(version, locale, self.android_prefix) - - def download(self, version, build, arch, locale): + def download(self, version, build, architecture, locale): try: os.makedirs(self.download_dir) - except OSError: # XXX: Used for compatibility with Python. Use FileExistsError otherwise + except OSError: # XXX: Used for compatibility with Python 2. Use FileExistsError otherwise pass - common_filename = self.get_common_file_name(version, locale) - arch_file = self.get_arch_file(arch) - - for api_suffix in self.get_api_suffix(arch): - url = self.generate_url(version, build, locale, api_suffix, arch_file) - apk_url = url + self.apk_ext - checksum_url = url + self.checksums_ext - if arch in self.multi_api_archs: - filename = common_filename + arch_file + "-" + api_suffix - else: - filename = common_filename + arch_file - - filename_apk = os.path.join(self.download_dir, filename + self.apk_ext) - filename_checksums = os.path.join(self.download_dir, filename + self.checksums_ext) + for api_suffix in self.get_api_suffix(architecture): + apk_base_url = self.generate_apk_base_url(version, build, locale, api_suffix) + apk, checksums = craft_apk_and_checksums_url_and_download_locations( + apk_base_url, self.download_dir, version, locale, architecture + ) - download_file(apk_url, filename_apk) - download_file(checksum_url, filename_checksums) + download_file(apk['url'], apk['download_location']) + download_file(checksums['url'], checksums['download_location']) - self.check_apk(filename_apk, filename_checksums) + check_apk_against_checksum_file(apk['download_location'], checksums['download_location']) def get_version_name(self): if self.config.latest_nightly or self.config.latest_aurora: @@ -153,24 +103,76 @@ def get_version_name(self): # Download all the archs if none is given def download_all(self, version, build, locale): - for arch in self.arch_values: - self.download(version, build, arch, locale) + for architecture in self.arch_values: + self.download(version, build, architecture, locale) - # Download apk initial action - def download_apk(self): + def run(self): version = self.get_version_name() - arch = self.config.arch + architecture = self.config.arch build = str(self.config.build) locale = self.config.locale - logger.info('Downloading version "{}" build #{} for arch "{}" (locale "{}")'.format(version, build, arch, locale)) - if arch == "all": + logger.info('Downloading version "{}" build #{} for arch "{}" (locale "{}")'.format(version, build, architecture, locale)) + if architecture == "all": self.download_all(version, build, locale) else: - self.download(version, build, arch, locale) + self.download(version, build, architecture, locale) - def run(self): - self.download_apk() + +def craft_apk_and_checksums_url_and_download_locations(base_apk_url, download_directory, version, locale, architecture): + file_names = _craft_apk_and_checksums_file_names(version, locale, architecture) + + return [ + { + 'download_location': os.path.join(download_directory, file_name), + 'url': '/'.join([base_apk_url, file_name]), + } for file_name in file_names + ] + + +def _craft_apk_and_checksums_file_names(version, locale, architecture): + file_name_architecture = _get_architecture_in_file_name(architecture) + extensions = ['apk', 'checksums'] + + return [ + 'fennec-{}.{}.android-{}.{}'.format(version, locale, file_name_architecture, extension) + for extension in extensions + ] + + +def _get_architecture_in_file_name(architecture): + # the file name contains i386 instead of x86 + return 'i386' if architecture == 'x86' else architecture + + +def check_apk_against_checksum_file(apk_file, checksum_file): + logger.debug('Checking checksum for "{}"...'.format(apk_file)) + + checksum = _fetch_checksum_from_file(checksum_file, apk_file) + apk_checksum = file_sha512sum(apk_file) + + if checksum == apk_checksum: + logger.info('Checksum for "{}" succeeded!'.format(apk_file)) + os.remove(checksum_file) + else: + raise CheckSumMismatch(apk_file, expected=apk_checksum, actual=checksum) + + +def _fetch_checksum_from_file(checksum_file, apk_file): + base_apk_filename = os.path.basename(apk_file) + with open(checksum_file, 'r') as fh: + for line in fh: + m = re.match(r"""^(?P.*) sha512 (?P\d+) {}""".format(base_apk_filename), line) + if m: + gd = m.groupdict() + logger.info("Found hash {}".format(gd['hash'])) + return gd['hash'] + # old style pre-Fennec 53 checksums files + with open(checksum_file, 'r') as f: + checksum = f.read() + checksum = re.sub("\s(.*)", "", checksum.splitlines()[0]) + logger.info("Found hash {}".format(checksum)) + return checksum if __name__ == '__main__': diff --git a/mozapkpublisher/test/test_get_apk.py b/mozapkpublisher/test/test_get_apk.py index 5106f47f..19b1aca8 100644 --- a/mozapkpublisher/test/test_get_apk.py +++ b/mozapkpublisher/test/test_get_apk.py @@ -5,7 +5,7 @@ from tempfile import mkdtemp from mozapkpublisher.exceptions import CheckSumMismatch, WrongArgumentGiven -from mozapkpublisher.get_apk import GetAPK +from mozapkpublisher.get_apk import GetAPK, _craft_apk_and_checksums_file_names, _get_architecture_in_file_name, check_apk_against_checksum_file VALID_CONFIG = {'version': '50.0b8'} CHECKSUM_APK = os.path.join(os.path.dirname(__file__), 'data', 'blob') @@ -26,20 +26,15 @@ def test_mutually_exclusive_group(): GetAPK({'latest_nightly': True, 'latest_aurora': True}) -def test_generate_url(): - assert GetAPK({'latest_nightly': True}).generate_url('52.0a1', None, 'multi', 'x86', 'i386') == \ - 'https://ftp.mozilla.org/pub/mobile/nightly/latest-mozilla-central-android-x86/fennec-52.0a1.multi.android-i386' +def test_generate_apk_base_url(): + assert GetAPK({'latest_nightly': True}).generate_apk_base_url('52.0a1', None, 'multi', 'x86') == \ + 'https://ftp.mozilla.org/pub/mobile/nightly/latest-mozilla-central-android-x86' - assert GetAPK({'latest_aurora': True}).generate_url('51.0a2', None, 'en-US', 'api-15', 'arm') == \ - 'https://ftp.mozilla.org/pub/mobile/nightly/latest-mozilla-aurora-android-api-15/fennec-51.0a2.en-US.android-arm' + assert GetAPK({'latest_aurora': True}).generate_apk_base_url('51.0a2', None, 'en-US', 'api-15') == \ + 'https://ftp.mozilla.org/pub/mobile/nightly/latest-mozilla-aurora-android-api-15' - assert GetAPK({'version': '50.0b8'}).generate_url('50.0b8', 1, 'multi', 'api-15', 'arm') == \ - 'https://ftp.mozilla.org/pub/mobile/candidates/50.0b8-candidates/build1/android-api-15/multi/fennec-50.0b8.multi.android-arm' - - -def test_get_arch_file(): - assert get_apk.get_arch_file('arm') == 'arm' - assert get_apk.get_arch_file('x86') == 'i386' + assert GetAPK({'version': '50.0b8'}).generate_apk_base_url('50.0b8', 1, 'multi', 'api-15') == \ + 'https://ftp.mozilla.org/pub/mobile/candidates/50.0b8-candidates/build1/android-api-15/multi' def test_get_api_suffix(): @@ -47,9 +42,16 @@ def test_get_api_suffix(): assert get_apk.get_api_suffix('x86') == ['x86'] -def test_get_common_file_name(): - assert get_apk.get_common_file_name('50.0b8', 'multi') == 'fennec-50.0b8.multi.android-' - assert get_apk.get_common_file_name('51.0a2', 'en-US') == 'fennec-51.0a2.en-US.android-' +def test_craft_apk_and_checksums_file_names(): + assert _craft_apk_and_checksums_file_names('50.0b8', 'multi', 'arm') == \ + ['fennec-50.0b8.multi.android-arm.apk', 'fennec-50.0b8.multi.android-arm.checksums'] + assert _craft_apk_and_checksums_file_names('51.0a2', 'en-US', 'x86') == \ + ['fennec-51.0a2.en-US.android-i386.apk', 'fennec-51.0a2.en-US.android-i386.checksums'] + + +def test_get_architecture_in_file_name(): + assert _get_architecture_in_file_name('arm') == 'arm' + assert _get_architecture_in_file_name('x86') == 'i386' @pytest.mark.parametrize('checksum_file,raises', (( @@ -59,7 +61,7 @@ def test_get_common_file_name(): ), ( os.path.join(os.path.dirname(__file__), 'data', 'checksums.broken'), True, ))) -def test_check_apk(checksum_file, raises): +def test_check_apk_against_checksum_file(checksum_file, raises): try: # check_apk nukes the checksum file, so make a copy first temp_dir = mkdtemp() @@ -68,8 +70,8 @@ def test_check_apk(checksum_file, raises): with mock.patch.object(shutil, 'rmtree'): if raises: with pytest.raises(CheckSumMismatch): - get_apk.check_apk(CHECKSUM_APK, cfile) + check_apk_against_checksum_file(CHECKSUM_APK, cfile) else: - assert get_apk.check_apk(CHECKSUM_APK, cfile) is None + assert check_apk_against_checksum_file(CHECKSUM_APK, cfile) is None finally: shutil.rmtree(temp_dir) From 05a484e88692cb3f7d908edaa4dade458a17387c Mon Sep 17 00:00:00 2001 From: Johan Lorenzo Date: Fri, 10 Mar 2017 12:49:24 +0100 Subject: [PATCH 2/4] get_apk: Add new unit tests --- mozapkpublisher/test/test_get_apk.py | 44 +++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/mozapkpublisher/test/test_get_apk.py b/mozapkpublisher/test/test_get_apk.py index 19b1aca8..9fa01b7f 100644 --- a/mozapkpublisher/test/test_get_apk.py +++ b/mozapkpublisher/test/test_get_apk.py @@ -5,12 +5,16 @@ from tempfile import mkdtemp from mozapkpublisher.exceptions import CheckSumMismatch, WrongArgumentGiven -from mozapkpublisher.get_apk import GetAPK, _craft_apk_and_checksums_file_names, _get_architecture_in_file_name, check_apk_against_checksum_file +from mozapkpublisher.get_apk import GetAPK, \ + craft_apk_and_checksums_url_and_download_locations, _craft_apk_and_checksums_file_names, _get_architecture_in_file_name, \ + check_apk_against_checksum_file, _fetch_checksum_from_file VALID_CONFIG = {'version': '50.0b8'} CHECKSUM_APK = os.path.join(os.path.dirname(__file__), 'data', 'blob') get_apk = GetAPK(VALID_CONFIG) +DATA_DIR = os.path.join(os.path.dirname(__file__), 'data') + def test_mutually_exclusive_group(): with pytest.raises(WrongArgumentGiven): @@ -42,6 +46,29 @@ def test_get_api_suffix(): assert get_apk.get_api_suffix('x86') == ['x86'] +def test_craft_apk_and_checksums_url_and_download_locations(): + assert craft_apk_and_checksums_url_and_download_locations( + 'https://ftp.mozilla.org/pub/mobile/candidates/52.0b1-candidates/build1/android-api-15/multi', + '/a/fake/download/directory', '52.0b1', 'multi', 'arm' + ) == [{ + 'url': 'https://ftp.mozilla.org/pub/mobile/candidates/52.0b1-candidates/build1/android-api-15/multi/fennec-52.0b1.multi.android-arm.apk', + 'download_location': '/a/fake/download/directory/fennec-52.0b1.multi.android-arm.apk', + }, { + 'url': 'https://ftp.mozilla.org/pub/mobile/candidates/52.0b1-candidates/build1/android-api-15/multi/fennec-52.0b1.multi.android-arm.checksums', + 'download_location': '/a/fake/download/directory/fennec-52.0b1.multi.android-arm.checksums', + }] + assert craft_apk_and_checksums_url_and_download_locations( + 'https://ftp.mozilla.org/pub/mobile/nightly/latest-mozilla-aurora-android-api-15', + '/a/fake/download/directory', '53.0a2', 'multi', 'arm' + ) == [{ + 'url': 'https://ftp.mozilla.org/pub/mobile/nightly/latest-mozilla-aurora-android-api-15/fennec-53.0a2.multi.android-arm.apk', + 'download_location': '/a/fake/download/directory/fennec-53.0a2.multi.android-arm.apk', + }, { + 'url': 'https://ftp.mozilla.org/pub/mobile/nightly/latest-mozilla-aurora-android-api-15/fennec-53.0a2.multi.android-arm.checksums', + 'download_location': '/a/fake/download/directory/fennec-53.0a2.multi.android-arm.checksums', + }] + + def test_craft_apk_and_checksums_file_names(): assert _craft_apk_and_checksums_file_names('50.0b8', 'multi', 'arm') == \ ['fennec-50.0b8.multi.android-arm.apk', 'fennec-50.0b8.multi.android-arm.checksums'] @@ -55,11 +82,11 @@ def test_get_architecture_in_file_name(): @pytest.mark.parametrize('checksum_file,raises', (( - os.path.join(os.path.dirname(__file__), 'data', 'checksums.old'), False, + os.path.join(DATA_DIR, 'checksums.old'), False, ), ( - os.path.join(os.path.dirname(__file__), 'data', 'checksums.tc'), False, + os.path.join(DATA_DIR, 'checksums.tc'), False, ), ( - os.path.join(os.path.dirname(__file__), 'data', 'checksums.broken'), True, + os.path.join(DATA_DIR, 'checksums.broken'), True, ))) def test_check_apk_against_checksum_file(checksum_file, raises): try: @@ -75,3 +102,12 @@ def test_check_apk_against_checksum_file(checksum_file, raises): assert check_apk_against_checksum_file(CHECKSUM_APK, cfile) is None finally: shutil.rmtree(temp_dir) + + +@pytest.mark.parametrize('checksum_file', ( + os.path.join(DATA_DIR, 'checksums.old'), + os.path.join(DATA_DIR, 'checksums.tc'), +)) +def test_fetch_checksum_from_file(checksum_file): + assert _fetch_checksum_from_file(checksum_file, CHECKSUM_APK) == \ + 'd5ee2608eb21d827deef87732dc4796c7209098b8db39f95c3fac87c0dc7b186f2b097f0a52e856e6ac504ff3039a3e23936615be55a172665cdd0250f3a4379' From c9fd3f75105fc49247a8fc6641b5e316aeb8e7ba Mon Sep 17 00:00:00 2001 From: Johan Lorenzo Date: Fri, 10 Mar 2017 16:50:58 +0100 Subject: [PATCH 3/4] get_apk: Add integration test --- .travis.yml | 3 ++ mozapkpublisher/get_apk.py | 14 ++++---- .../test/integration/test_get_apk.py | 32 +++++++++++++++++++ tox.ini | 1 + 4 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 mozapkpublisher/test/integration/test_get_apk.py diff --git a/.travis.yml b/.travis.yml index b0b2e955..b434d497 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,6 +5,9 @@ python: - "3.5" - "2.7" +env: + - SKIP_NETWORK_TESTS=0 + install: - travis_retry pip install tox script: diff --git a/mozapkpublisher/get_apk.py b/mozapkpublisher/get_apk.py index 3ebd3750..8a3f0662 100755 --- a/mozapkpublisher/get_apk.py +++ b/mozapkpublisher/get_apk.py @@ -21,8 +21,6 @@ class GetAPK(Base): multi_api_archs = ["arm"] multi_apis = ['api-15'] # v11 has been dropped in fx 46 (bug 1155801) and v9 in fx 48 (bug 1220184) - download_dir = "apk-download" - json_version_url = "https://product-details.mozilla.org/1.0/firefox_versions.json" def __init__(self, config=None): @@ -49,6 +47,10 @@ def _init_parser(cls): help='Specify which architecture to get the apk for. Will download every architecture if not set.' ) cls.parser.add_argument('--locale', default='multi', help='Specify which locale to get the apk for') + cls.parser.add_argument( + '--output-directory', dest='download_directory', default='apk-download', + help='Directory in which APKs will be downloaded to. Will be created if needed.' + ) # Cleanup half downloaded files on Ctrl+C def signal_handler(self, signal, frame): @@ -58,10 +60,10 @@ def signal_handler(self, signal, frame): def cleanup(self): try: - shutil.rmtree(self.download_dir) + shutil.rmtree(self.config.download_directory) logger.info('Download directory cleaned') except OSError: # XXX: Used for compatibility with Python 2. Use FileNotFoundError otherwise - logger.warn('{} was not found. Skipping...'.format(self.download_dir)) + logger.warn('{} was not found. Skipping...'.format(self.config.download_directory)) def generate_apk_base_url(self, version, build, locale, api_suffix): if self.config.latest_nightly or self.config.latest_aurora: @@ -79,14 +81,14 @@ def get_api_suffix(self, arch): def download(self, version, build, architecture, locale): try: - os.makedirs(self.download_dir) + os.makedirs(self.config.download_directory) except OSError: # XXX: Used for compatibility with Python 2. Use FileExistsError otherwise pass for api_suffix in self.get_api_suffix(architecture): apk_base_url = self.generate_apk_base_url(version, build, locale, api_suffix) apk, checksums = craft_apk_and_checksums_url_and_download_locations( - apk_base_url, self.download_dir, version, locale, architecture + apk_base_url, self.config.download_directory, version, locale, architecture ) download_file(apk['url'], apk['download_location']) diff --git a/mozapkpublisher/test/integration/test_get_apk.py b/mozapkpublisher/test/integration/test_get_apk.py new file mode 100644 index 00000000..5b8528d3 --- /dev/null +++ b/mozapkpublisher/test/integration/test_get_apk.py @@ -0,0 +1,32 @@ +import pytest +import os +import re +import shutil +import tempfile + +from distutils.util import strtobool + +from mozapkpublisher.get_apk import GetAPK + + +@pytest.mark.skipif(strtobool(os.environ.get('SKIP_NETWORK_TESTS', 'true')), reason='Tests requiring network are skipped') +@pytest.mark.parametrize('get_apk_args, apks_file_regexes', ( + ({'latest_aurora': True}, (r'fennec-\d{2}\.0a2\.multi\.android-arm\.apk', r'fennec-\d{2}\.0a2\.multi\.android-i386\.apk')), + ({'latest_nightly': True, 'arch': 'x86'}, (r'fennec-\d{2}\.0a1\.multi\.android-i386\.apk',)), + # Pre-Fennec 53.0b1 + ({'version': '52.0', 'build': '2', 'arch': 'arm'}, (r'fennec-52\.0\.multi\.android-arm\.apk',)), + ({'version': '53.0b1', 'build': '3', 'arch': 'arm'}, (r'fennec-53\.0b1\.multi\.android-arm\.apk',)), +)) +def test_aurora(get_apk_args, apks_file_regexes): + temp_dir = tempfile.mkdtemp() + get_apk_args['output-directory'] = temp_dir + GetAPK(get_apk_args).run() + files_in_temp_dir = [ + name for name in os.listdir(temp_dir) if os.path.isfile(os.path.join(temp_dir, name)) + ] + assert len(files_in_temp_dir) == len(apks_file_regexes) + + for regex in apks_file_regexes: + assert any(re.match(regex, file_name) for file_name in files_in_temp_dir) + + shutil.rmtree(temp_dir) diff --git a/tox.ini b/tox.ini index ae9ceeed..82f6cf38 100644 --- a/tox.ini +++ b/tox.ini @@ -10,6 +10,7 @@ passenv = TRAVIS TRAVIS_JOB_ID TRAVIS_BRANCH + SKIP_NETWORK_TESTS deps = coverage From 685855932a07f12f276c3937213593e3e2ec39c0 Mon Sep 17 00:00:00 2001 From: Johan Lorenzo Date: Fri, 10 Mar 2017 16:53:38 +0100 Subject: [PATCH 4/4] get_apk: Remove --clean feature --- mozapkpublisher/get_apk.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/mozapkpublisher/get_apk.py b/mozapkpublisher/get_apk.py index 8a3f0662..04e35881 100755 --- a/mozapkpublisher/get_apk.py +++ b/mozapkpublisher/get_apk.py @@ -33,8 +33,6 @@ def _init_parser(cls): ) exclusive_group = cls.parser.add_mutually_exclusive_group(required=True) - exclusive_group.add_argument('--clean', action='store_true', default=False, - help='Use this option to clean the download directory') exclusive_group.add_argument('--version', default=None, help='Specify version number to download (e.g. 23.0b7)') exclusive_group.add_argument('--latest-nightly', action='store_true', default=False, help='Download the latest nightly version')