From 7f0bd8bfc628295bd5edd443f86896a02812f677 Mon Sep 17 00:00:00 2001 From: deathaxe Date: Sat, 28 Oct 2023 17:33:43 +0200 Subject: [PATCH] Sync with Package Control --- app/lib/package_control/__init__.py | 4 +- .../clients/bitbucket_client.py | 12 ++ .../package_control/clients/github_client.py | 155 ++++++++++++++++ .../package_control/clients/gitlab_client.py | 166 +++++++++++++++++- .../clients/json_api_client.py | 106 +++++++++++ .../providers/json_repository_provider.py | 160 ++++++++++++----- 6 files changed, 550 insertions(+), 53 deletions(-) diff --git a/app/lib/package_control/__init__.py b/app/lib/package_control/__init__.py index 5672732..1c7082d 100644 --- a/app/lib/package_control/__init__.py +++ b/app/lib/package_control/__init__.py @@ -1,2 +1,2 @@ -__version__ = "4.0.0-beta8" -__version_info__ = (4, 0, 0, 'beta', 8) +__version__ = "4.0.0-beta9" +__version_info__ = (4, 0, 0, 'beta', 9) diff --git a/app/lib/package_control/clients/bitbucket_client.py b/app/lib/package_control/clients/bitbucket_client.py index fdc5732..4d61f30 100644 --- a/app/lib/package_control/clients/bitbucket_client.py +++ b/app/lib/package_control/clients/bitbucket_client.py @@ -146,6 +146,18 @@ def download_info_from_branch(self, url, default_branch=None): return [self._make_download_info(user_repo, branch, version, timestamp)] + def download_info_from_releases(self, url, asset_templates, tag_prefix=None): + """ + BitBucket doesn't support releases in ways GitHub/Gitlab do. + + It supports download assets, but those are not bound to tags or releases. + + Version information could be extracted from file names, + but that's not how PC evaluates download assets, currently. + """ + + return None + def download_info_from_tags(self, url, tag_prefix=None): """ Retrieve information about downloading a package diff --git a/app/lib/package_control/clients/github_client.py b/app/lib/package_control/clients/github_client.py index 2bb46e2..1650445 100644 --- a/app/lib/package_control/clients/github_client.py +++ b/app/lib/package_control/clients/github_client.py @@ -131,6 +131,161 @@ def download_info_from_branch(self, url, default_branch=None): return [self._make_download_info(user_repo, branch, version, timestamp)] + def download_info_from_releases(self, url, asset_templates, tag_prefix=None): + """ + Retrieve information about downloading a package + + :param url: + The URL of the repository, in one of the forms: + https://github.com/{user}/{repo} + https://github.com/{user}/{repo}/releases + Grabs the info from the newest tag(s) that is a valid semver version. + + :param tag_prefix: + If the URL is a tags URL, only match tags that have this prefix. + If tag_prefix is None, match only tags without prefix. + + :param asset_templates: + A list of tuples of asset template and download_info. + + [ + ( + "Name-${version}-st${st_build}-*-x??.sublime", + { + "platforms": ["windows-x64"], + "python_versions": ["3.3", "3.8"], + "sublime_text": ">=4107" + } + ) + ] + + Supported globs: + + * : any number of characters + ? : single character placeholder + + Supported variables are: + + ${platform} + A platform-arch string as given in "platforms" list. + A separate explicit release is evaluated for each platform. + If "platforms": ['*'] is specified, variable is set to "any". + + ${py_version} + Major and minor part of required python version without period. + One of "33", "38" or any other valid python version supported by ST. + + ${st_build} + Value of "st_specifier" stripped by leading operator + "*" => "any" + ">=4107" => "4107" + "<4107" => "4107" + "4107 - 4126" => "4107" + + ${version} + Resolved semver without tag prefix + (e.g.: tag st4107-1.0.5 => version 1.0.5) + + Note: is not replaced by this method, but by the ``ClientProvider``. + + :raises: + DownloaderException: when there is an error downloading + ClientException: when there is an error parsing the response + + :return: + ``None`` if no match, ``False`` if no commit, or a list of dicts with the + following keys: + + - `version` - the version number of the download + - `url` - the download URL of a zip file of the package + - `date` - the ISO-8601 timestamp string when the version was published + - `platforms` - list of unicode strings with compatible platforms + - `python_versions` - list of compatible python versions + - `sublime_text` - sublime text version specifier + + Example: + + ```py + [ + { + "url": "https://server.com/file.zip", + "version": "1.0.0", + "date": "2023-10-21 12:00:00", + "platforms": ["windows-x64"], + "python_versions": ["3.8"], + "sublime_text": ">=4107" + }, + ... + ] + ``` + """ + + match = re.match(r'https?://github\.com/([^/#?]+/[^/#?]+)(?:/releases)?/?$', url) + if not match: + return None + + def _get_releases(user_repo, tag_prefix=None, page_size=1000): + used_versions = set() + for page in range(10): + query_string = urlencode({'page': page * page_size, 'per_page': page_size}) + api_url = self._api_url(user_repo, '/releases?%s' % query_string) + releases = self.fetch_json(api_url) + + for release in releases: + if release['draft']: + continue + version = version_match_prefix(release['tag_name'], tag_prefix) + if not version or version in used_versions: + continue + + used_versions.add(version) + + yield ( + version, + release['published_at'][0:19].replace('T', ' '), + [ + ((a['label'], a['browser_download_url'])) + for a in release['assets'] + if a['state'] == 'uploaded' + ] + ) + + if len(releases) < page_size: + return + + user_repo = match.group(1) + max_releases = self.settings.get('max_releases', 0) + num_releases = 0 + + asset_templates = self._expand_asset_variables(asset_templates) + + output = [] + for release in _get_releases(user_repo, tag_prefix): + version, timestamp, assets = release + + version_string = str(version) + + for pattern, selectors in asset_templates: + pattern = pattern.replace('${version}', version_string) + pattern = pattern.replace('.', r'\.') + pattern = pattern.replace('*', r'.*?') + pattern = pattern.replace('?', r'.') + regex = re.compile(pattern) + + for asset_name, asset_url in assets: + if not regex.match(asset_name): + continue + + info = {'url': asset_url, 'version': version_string, 'date': timestamp} + info.update(selectors) + output.append(info) + + num_releases += version.is_final + if max_releases > 0 and num_releases >= max_releases: + break + + return output + def download_info_from_tags(self, url, tag_prefix=None): """ Retrieve information about downloading a package diff --git a/app/lib/package_control/clients/gitlab_client.py b/app/lib/package_control/clients/gitlab_client.py index 80f0272..c75a22d 100644 --- a/app/lib/package_control/clients/gitlab_client.py +++ b/app/lib/package_control/clients/gitlab_client.py @@ -42,7 +42,7 @@ def user_repo_branch(url): @staticmethod def repo_url(user_name, repo_name): """ - Generate the tags URL for a GitHub repo if the value passed is a GitHub + Generate the tags URL for a GitLab repo if the value passed is a GitLab repository URL :param owener_name: @@ -134,6 +134,160 @@ def download_info_from_branch(self, url, default_branch=None): return [self._make_download_info(user_name, repo_name, branch, version, timestamp)] + def download_info_from_releases(self, url, asset_templates, tag_prefix=None): + """ + Retrieve information about downloading a package + + :param url: + The URL of the repository, in one of the forms: + https://gitlab.com/{user}/{repo} + https://gitlab.com/{user}/{repo}/-/releases + Grabs the info from the newest tag(s) that is a valid semver version. + + :param tag_prefix: + If the URL is a tags URL, only match tags that have this prefix. + If tag_prefix is None, match only tags without prefix. + + :param asset_templates: + A list of tuples of asset template and download_info. + + [ + ( + "Name-${version}-st${st_build}-*-x??.sublime", + { + "platforms": ["windows-x64"], + "python_versions": ["3.3", "3.8"], + "sublime_text": ">=4107" + } + ) + ] + + Supported globs: + + * : any number of characters + ? : single character placeholder + + Supported variables are: + + ${platform} + A platform-arch string as given in "platforms" list. + A separate explicit release is evaluated for each platform. + If "platforms": ['*'] is specified, variable is set to "any". + + ${py_version} + Major and minor part of required python version without period. + One of "33", "38" or any other valid python version supported by ST. + + ${st_build} + Value of "st_specifier" stripped by leading operator + "*" => "any" + ">=4107" => "4107" + "<4107" => "4107" + "4107 - 4126" => "4107" + + ${version} + Resolved semver without tag prefix + (e.g.: tag st4107-1.0.5 => version 1.0.5) + + Note: is not replaced by this method, but by the ``ClientProvider``. + + :raises: + DownloaderException: when there is an error downloading + ClientException: when there is an error parsing the response + + :return: + ``None`` if no match, ``False`` if no commit, or a list of dicts with the + following keys: + + - `version` - the version number of the download + - `url` - the download URL of a zip file of the package + - `date` - the ISO-8601 timestamp string when the version was published + - `platforms` - list of unicode strings with compatible platforms + - `python_versions` - list of compatible python versions + - `sublime_text` - sublime text version specifier + + Example: + + ```py + [ + { + "url": "https://server.com/file.zip", + "version": "1.0.0", + "date": "2023-10-21 12:00:00", + "platforms": ["windows-x64"], + "python_versions": ["3.8"], + "sublime_text": ">=4107" + }, + ... + ] + ``` + """ + + match = re.match(r'https?://gitlab\.com/([^/#?]+)/([^/#?]+)(?:/-/releases)?/?$', url) + if not match: + return None + + def _get_releases(user_repo, tag_prefix=None, page_size=1000): + used_versions = set() + for page in range(10): + query_string = urlencode({'page': page * page_size, 'per_page': page_size}) + api_url = self._api_url(user_repo, '/releases?%s' % query_string) + releases = self.fetch_json(api_url) + + for release in releases: + version = version_match_prefix(release['tag_name'], tag_prefix) + if not version or version in used_versions: + continue + + used_versions.add(version) + + yield ( + version, + release['released_at'][0:19].replace('T', ' '), + [ + ((a['name'], a['direct_asset_url'])) + for a in release['assets']['links'] + ] + ) + + if len(releases) < page_size: + return + + user_name, repo_name = match.groups() + repo_id = '%s%%2F%s' % (user_name, repo_name) + + max_releases = self.settings.get('max_releases', 0) + num_releases = 0 + + asset_templates = self._expand_asset_variables(asset_templates) + + output = [] + for release in _get_releases(repo_id, tag_prefix): + version, timestamp, assets = release + + version_string = str(version) + + for pattern, selectors in asset_templates: + pattern = pattern.replace('${version}', version_string) + pattern = pattern.replace('.', r'\.') + pattern = pattern.replace('*', r'.*?') + pattern = pattern.replace('?', r'.') + regex = re.compile(pattern) + + for asset_name, asset_url in assets: + if not regex.match(asset_name): + continue + + info = {'url': asset_url, 'version': version_string, 'date': timestamp} + info.update(selectors) + output.append(info) + + num_releases += version.is_final + if max_releases > 0 and num_releases >= max_releases: + break + + return output + def download_info_from_tags(self, url, tag_prefix=None): """ Retrieve information about downloading a package @@ -164,11 +318,11 @@ def download_info_from_tags(self, url, tag_prefix=None): if not tags_match: return None - def _get_releases(user_repo, tag_prefix=None, page_size=1000): + def _get_releases(repo_id, tag_prefix=None, page_size=1000): used_versions = set() for page in range(10): query_string = urlencode({'page': page * page_size, 'per_page': page_size}) - tags_url = self._api_url(user_repo, '/repository/tags?%s' % query_string) + tags_url = self._api_url(repo_id, '/repository/tags?%s' % query_string) tags_json = self.fetch_json(tags_url) for tag in tags_json: @@ -185,13 +339,13 @@ def _get_releases(user_repo, tag_prefix=None, page_size=1000): return user_name, repo_name = tags_match.groups() - user_repo = '%s%%2F%s' % (user_name, repo_name) + repo_id = '%s%%2F%s' % (user_name, repo_name) max_releases = self.settings.get('max_releases', 0) num_releases = 0 output = [] - for release in sorted(_get_releases(user_repo, tag_prefix), reverse=True): + for release in sorted(_get_releases(repo_id, tag_prefix), reverse=True): version, tag, timestamp = release output.append(self._make_download_info(user_name, repo_name, tag, str(version), timestamp)) @@ -227,7 +381,7 @@ def repo_info(self, url): """ user_name, repo_name, branch = self.user_repo_branch(url) - if not repo_name: + if not user_name or not repo_name: return None repo_id = '%s%%2F%s' % (user_name, repo_name) diff --git a/app/lib/package_control/clients/json_api_client.py b/app/lib/package_control/clients/json_api_client.py index 73ff561..72da376 100644 --- a/app/lib/package_control/clients/json_api_client.py +++ b/app/lib/package_control/clients/json_api_client.py @@ -61,3 +61,109 @@ def fetch_json(self, url, prefer_cached=False): except (ValueError): error_string = 'Error parsing JSON from URL %s.' % url raise ClientException(error_string) + + @staticmethod + def _expand_asset_variables(asset_templates): + """ + Expands the asset variables. + + Note: ``${version}`` is not replaced. + + :param asset_templates: + A list of tuples of asset template and download_info. + + ```py + [ + ( + "Name-${version}-py${py_version}-*-x??.whl", + { + "platforms": ["windows-x64"], + "python_versions": ["3.3", "3.8"], + "sublime_text": ">=4107" + } + ) + ] + ``` + + Supported variables are: + + ``${platform}`` + A platform-arch string as given in "platforms" list. + A separate explicit release is evaluated for each platform. + If "platforms": ['*'] is specified, variable is set to "any". + + ``${py_version}`` + Major and minor part of required python version without period. + One of "33", "38" or any other valid python version supported by ST. + + ``${st_build}`` + Value of "st_specifier" stripped by leading operator + "*" => "any" + ">=4107" => "4107" + "<4107" => "4107" + "4107 - 4126" => "4107" + + :returns: + A list of asset templates with all variables (except ``${version}``) resolved. + + ```py + [ + ( + "Name-${version}-py33-*-x??.whl", + { + "platforms": ["windows-x64"], + "python_versions": ["3.3"], + "sublime_text": ">=4107" + } + ), + ( + "Name-${version}-py33-*-x??.whl", + { + "platforms": ["windows-x64"], + "python_versions": ["3.8"], + "sublime_text": ">=4107" + } + ) + ] + ``` + """ + + output = [] + var = '${st_build}' + for pattern, selectors in asset_templates: + # resolve ${st_build} + if var in pattern: + # convert st_specifier version specifier to build number + st_specifier = selectors['sublime_text'] + if st_specifier == '*': + st_build = 'any' + elif st_specifier[0].isdigit(): + # 4107, 4107 - 4126 + st_build = st_specifier[:4] + elif st_specifier[1].isdigit(): + # <4107, >4107 + st_build = st_specifier[1:] + else: + # ==4107, <=4107, >=4107 + st_build = st_specifier[2:] + + pattern = pattern.replace(var, st_build) + + output.append((pattern, selectors)) + + def resolve(templates, var, key): + for pattern, selectors in templates: + if var not in pattern: + yield (pattern, selectors) + continue + + for platform in selectors[key]: + new_selectors = selectors.copy() + new_selectors[key] = [platform] + yield (pattern.replace(var, platform), new_selectors) + + return None + + output = resolve(output, '${platform}', 'platforms') + output = resolve(output, '${py_version}', 'python_versions') + return list(output) diff --git a/app/lib/package_control/providers/json_repository_provider.py b/app/lib/package_control/providers/json_repository_provider.py index e5eb247..c479282 100644 --- a/app/lib/package_control/providers/json_repository_provider.py +++ b/app/lib/package_control/providers/json_repository_provider.py @@ -16,6 +16,14 @@ from .provider_exception import ProviderException from .schema_version import SchemaVersion +try: + # running within ST + from ..selectors import is_compatible_platform, is_compatible_version + IS_ST = True +except ImportError: + # running on CLI or server + IS_ST = False + class InvalidRepoFileException(ProviderException): def __init__(self, repo, reason_message): @@ -236,25 +244,26 @@ def get_libraries(self, invalid_sources=None): if not self.fetch(): return + if not self.repo_info: + return + if self.schema_version.major >= 4: allowed_library_keys = { 'name', 'description', 'author', 'homepage', 'issues', 'releases' } allowed_release_keys = { # todo: remove 'branch' - 'base', 'version', 'sublime_text', 'platforms', 'python_versions', 'branch', 'tags', 'url', 'sha256' + 'base', 'version', 'sublime_text', 'platforms', 'python_versions', + 'branch', 'tags', 'asset', 'url', 'sha256' } else: allowed_library_keys = { 'name', 'description', 'author', 'issues', 'load_order', 'releases' } allowed_release_keys = { - 'base', 'version', 'sublime_text', 'platforms', 'branch', 'tags', 'url', 'sha256' + 'base', 'version', 'sublime_text', 'platforms', + 'branch', 'tags', 'url', 'sha256' } - required_library_keys = { - 'description', 'author', 'issues', 'releases' - } - copied_library_keys = ('name', 'description', 'author', 'homepage', 'issues') copied_release_keys = ('date', 'version', 'sha256') default_platforms = ['*'] @@ -268,7 +277,7 @@ def get_libraries(self, invalid_sources=None): ] output = {} - for library in self.repo_info['libraries']: + for library in self.repo_info.get('libraries', []): info = { 'releases': [], 'sources': [self.repo_url] @@ -303,6 +312,8 @@ def get_libraries(self, invalid_sources=None): ' in repository {}.'.format(info['name'], self.repo_url) ) + staged_releases = {} + for release in releases: download_info = {} @@ -331,6 +342,9 @@ def get_libraries(self, invalid_sources=None): value = [value] elif not isinstance(value, list): raise InvalidLibraryReleaseKeyError(self.repo_url, info['name'], key) + # ignore incompatible release (avoid downloading/evaluating further information) + if IS_ST and not is_compatible_platform(value): + continue download_info[key] = value # Validate supported python_versions @@ -347,6 +361,9 @@ def get_libraries(self, invalid_sources=None): value = release.get(key, default_sublime_text) if not isinstance(value, str): raise InvalidLibraryReleaseKeyError(self.repo_url, info['name'], key) + # ignore incompatible release (avoid downloading/evaluating further information) + if IS_ST and not is_compatible_version(value): + continue download_info[key] = value # Validate url @@ -387,13 +404,23 @@ def get_libraries(self, invalid_sources=None): downloads = None # Evaluate and resolve "tags" and "branch" release templates - tags = release.get('tags') + asset = release.get('asset') branch = release.get('branch') + tags = release.get('tags') + extra = None if tags is True else tags - if tags: - extra = None - if tags is not True: - extra = tags + if asset: + if branch: + raise ProviderException( + 'Illegal "asset" key "{}" for branch based release of library "{}"' + ' in repository "{}".'.format(base, info['name'], self.repo_url) + ) + # group releases with assets by base_url and tag-prefix + # to prepare gathering download_info with a single API call + staged_releases.setdefault((base_url, extra), []).append((asset, download_info)) + continue + + elif tags: for client in clients: downloads = client.download_info_from_tags(base_url, extra) if downloads is not None: @@ -426,14 +453,26 @@ def get_libraries(self, invalid_sources=None): download.update(download_info) info['releases'].append(download) + # gather download_info from releases + for (base_url, extra), asset_templates in staged_releases.items(): + for client in clients: + downloads = client.download_info_from_releases(base_url, asset_templates, extra) + if downloads is not None: + info['releases'].extend(downloads) + break + # check required library keys - for key in required_library_keys: + for key in ('description', 'author', 'issues'): if not info.get(key): raise ProviderException( 'Missing or invalid "{}" key for library "{}"' ' in repository "{}".'.format(key, info['name'], self.repo_url) ) + # Empty releases means package is unavailable on current platform or for version of ST + if not info['releases']: + continue + info['releases'] = version_sort(info['releases'], 'platforms', reverse=True) output[info['name']] = info @@ -494,7 +533,8 @@ def get_packages(self, invalid_sources=None): if not self.fetch(): return - required_package_keys = {'author', 'releases'} + if not self.repo_info: + return copied_package_keys = ( 'name', @@ -520,7 +560,7 @@ def get_packages(self, invalid_sources=None): ] output = {} - for package in self.repo_info['packages']: + for package in self.repo_info.get('packages', []): info = { 'releases': [], 'sources': [self.repo_url] @@ -574,14 +614,19 @@ def get_packages(self, invalid_sources=None): continue try: + if not info.get('author'): + raise ProviderException( + 'Missing or invalid "author" key for package "{}"' + ' in repository "{}".'.format(info['name'], self.repo_url) + ) + # evaluate releases releases = package.get('releases') - if self.schema_version.major == 2: - # If no releases info was specified, also grab the download info from GH or BB - if not releases and details: - releases = [{'details': details}] + # If no releases info was specified, also grab the download info from GH or BB + if self.schema_version.major == 2 and not releases and details: + releases = [{'details': details}] if not releases: raise ProviderException( @@ -595,6 +640,8 @@ def get_packages(self, invalid_sources=None): ' in the repository {}.'.format(info['name'], self.repo_url) ) + staged_releases = {} + # This allows developers to specify a GH or BB location to get releases from, # especially tags URLs (https://github.com/user/repo/tags or # https://bitbucket.org/user/repo#tags) @@ -617,22 +664,24 @@ def get_packages(self, invalid_sources=None): value = [value] elif not isinstance(value, list): raise InvalidPackageReleaseKeyError(self.repo_url, info['name'], key) + # ignore incompatible release (avoid downloading/evaluating further information) + if IS_ST and not is_compatible_platform(value): + continue download_info[key] = value - # Validate supported python_versions - if self.schema_version.major >= 4: - key = 'python_versions' - value = release.get(key) - if value: - # Package releases may optionally contain `python_versions` list to tell - # which python version they are compatibilible with. - # The main purpose is to be able to opt-in unmaintained packages to python 3.8 - # if they are known not to cause trouble. - if isinstance(value, str): - value = [value] - elif not isinstance(value, list): - raise InvalidPackageReleaseKeyError(self.repo_url, info['name'], key) - download_info[key] = value + # Validate supported python_versions (requires scheme 4.0.0!) + key = 'python_versions' + value = release.get(key) + if value: + # Package releases may optionally contain `python_versions` list to tell + # which python version they are compatibilible with. + # The main purpose is to be able to opt-in unmaintained packages to python 3.8 + # if they are known not to cause trouble. + if isinstance(value, str): + value = [value] + elif not isinstance(value, list): + raise InvalidPackageReleaseKeyError(self.repo_url, info['name'], key) + download_info[key] = value if self.schema_version.major >= 3: # Validate supported ST version @@ -641,6 +690,9 @@ def get_packages(self, invalid_sources=None): value = release.get(key, default_sublime_text) if not isinstance(value, str): raise InvalidPackageReleaseKeyError(self.repo_url, info['name'], key) + # ignore incompatible release (avoid downloading/evaluating further information) + if IS_ST and not is_compatible_version(value): + continue download_info[key] = value # Validate url @@ -680,17 +732,28 @@ def get_packages(self, invalid_sources=None): base_url = resolve_url(self.repo_url, base) downloads = None - tags = release.get('tags') + asset = release.get('asset') branch = release.get('branch') + tags = release.get('tags') + extra = None if tags is True else tags + + if asset: + if branch: + raise ProviderException( + 'Illegal "asset" key "{}" for branch based release of library "{}"' + ' in repository "{}".'.format(base, info['name'], self.repo_url) + ) + # group releases with assets by base_url and tag-prefix + # to prepare gathering download_info with a single API call + staged_releases.setdefault((base_url, extra), []).append((asset, download_info)) + continue - if tags: - extra = None - if tags is not True: - extra = tags + elif tags: for client in clients: downloads = client.download_info_from_tags(base_url, extra) if downloads is not None: break + elif branch: for client in clients: downloads = client.download_info_from_branch(base_url, branch) @@ -726,6 +789,9 @@ def get_packages(self, invalid_sources=None): continue if not isinstance(value, str): raise InvalidPackageReleaseKeyError(self.repo_url, info['name'], key) + # ignore incompatible release (avoid downloading/evaluating further information) + if IS_ST and not is_compatible_version(value): + continue download_info[key] = value # Validate url @@ -780,13 +846,17 @@ def get_packages(self, invalid_sources=None): download.update(download_info) info['releases'].append(download) - # check required package keys - for key in required_package_keys: - if not info.get(key): - raise ProviderException( - 'Missing or invalid "{}" key for package "{}"' - ' in repository "{}".'.format(key, info['name'], self.repo_url) - ) + # gather download_info from releases + for (base_url, extra), asset_templates in staged_releases.items(): + for client in clients: + downloads = client.download_info_from_releases(base_url, asset_templates, extra) + if downloads is not None: + info['releases'].extend(downloads) + break + + # Empty releases means package is unavailable on current platform or for version of ST + if not info['releases']: + continue info['releases'] = version_sort(info['releases'], 'platforms', reverse=True)