From 901aeaca41aa529443367fc71281fcc3e24b05b4 Mon Sep 17 00:00:00 2001 From: Chin Yeung Li Date: Wed, 19 Mar 2025 18:47:14 +0800 Subject: [PATCH 01/12] #468 - Added pypi collector It's now able to fetch JSON from PyPI API with inputing thr pypi purl with version and pass it to scan and return the scan result. ToDo item is the second part of the issue Signed-off-by: Chin Yeung Li --- minecode/collectors/pypi.py | 102 ++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 minecode/collectors/pypi.py diff --git a/minecode/collectors/pypi.py b/minecode/collectors/pypi.py new file mode 100644 index 00000000..f536b766 --- /dev/null +++ b/minecode/collectors/pypi.py @@ -0,0 +1,102 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# purldb is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/nexB/purldb for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + +import logging + +import requests +from packageurl import PackageURL + +from minecode import priority_router +from minecode.miners.pypi import build_packages +from packagedb.models import PackageContentType + +""" +Collect PyPI packages from pypi registries. +""" + +logger = logging.getLogger(__name__) +handler = logging.StreamHandler() +logger.addHandler(handler) +logger.setLevel(logging.INFO) + + +def get_package_json(name, version): + """ + Return the contents of the JSON file of the package described by the purl + field arguments in a string. + """ + # Create URLs using purl fields + url = f"https://pypi.org/pypi/{name}/{version}/json" + + try: + response = requests.get(url) + response.raise_for_status() + return response.json() + except requests.exceptions.HTTPError as err: + logger.error(f"HTTP error occurred: {err}") + + +def map_pypi_package(package_url, pipelines, priority=0): + """ + Add a pypi `package_url` to the PackageDB. + + Return an error string if any errors are encountered during the process + """ + from minecode.model_utils import add_package_to_scan_queue + from minecode.model_utils import merge_or_create_package + + package_json = get_package_json( + name=package_url.name, + version=package_url.version, + ) + + if not package_json: + error = f"Package does not exist on PyPI: {package_url}" + logger.error(error) + return error + + packages = build_packages(package_json, package_url) + for package in packages: + package.extra_data["package_content"] = PackageContentType.SOURCE_ARCHIVE + + db_package, _, _, error = merge_or_create_package(package, visit_level=0) + + # Submit package for scanning + if db_package: + add_package_to_scan_queue( + package=db_package, pipelines=pipelines, priority=priority + ) + + return error + + +@priority_router.route("pkg:pypi/.*") +def process_request(purl_str, **kwargs): + """ + Process `priority_resource_uri` containing a pypi Package URL (PURL) as a + URI. + + This involves obtaining Package information for the PURL from pypi and + using it to create a new PackageDB entry. The package is then added to the + scan queue afterwards. + """ + from minecode.model_utils import DEFAULT_PIPELINES + + addon_pipelines = kwargs.get("addon_pipelines", []) + pipelines = DEFAULT_PIPELINES + tuple(addon_pipelines) + priority = kwargs.get("priority", 0) + + package_url = PackageURL.from_string(purl_str) + if not package_url.version: + return + + error_msg = map_pypi_package(package_url, pipelines, priority) + + if error_msg: + return error_msg From e587d720dc52b257497e084f4bb22f9f591d12f3 Mon Sep 17 00:00:00 2001 From: Chin Yeung Li Date: Thu, 20 Mar 2025 17:58:48 +0800 Subject: [PATCH 02/12] #468 - Add support for PyPI packages without a version specified in the PURL. Signed-off-by: Chin Yeung Li --- minecode/collectors/pypi.py | 40 +++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/minecode/collectors/pypi.py b/minecode/collectors/pypi.py index f536b766..0c289f31 100644 --- a/minecode/collectors/pypi.py +++ b/minecode/collectors/pypi.py @@ -42,6 +42,22 @@ def get_package_json(name, version): logger.error(f"HTTP error occurred: {err}") +def get_all_package_version(name): + """ + Resturn a list of all version numbers for the package name. + """ + url = f"https://pypi.org/pypi/{name}/json" + try: + response = requests.get(url) + response.raise_for_status() + data = response.json() + # Get all available versions + versions = list(data["releases"].keys()) + return versions + except requests.exceptions.HTTPError as err: + logger.error(f"HTTP error occurred: {err}") + + def map_pypi_package(package_url, pipelines, priority=0): """ Add a pypi `package_url` to the PackageDB. @@ -62,6 +78,7 @@ def map_pypi_package(package_url, pipelines, priority=0): return error packages = build_packages(package_json, package_url) + for package in packages: package.extra_data["package_content"] = PackageContentType.SOURCE_ARCHIVE @@ -93,10 +110,21 @@ def process_request(purl_str, **kwargs): priority = kwargs.get("priority", 0) package_url = PackageURL.from_string(purl_str) - if not package_url.version: - return - - error_msg = map_pypi_package(package_url, pipelines, priority) - if error_msg: - return error_msg + if not package_url.version: + versions = get_all_package_version(package_url.name) + for version in versions: + # package_url.version cannot be set as it will raise + # AttributeError: can't set attribute + # package_url.version = version + purl = purl_str.replace('@','') + '@' + version + package_url = PackageURL.from_string(purl) + error_msg = map_pypi_package(package_url, pipelines, priority) + + if error_msg: + return error_msg + else: + error_msg = map_pypi_package(package_url, pipelines, priority) + + if error_msg: + return error_msg From 6383e6c5d77e0287ae4b2f943db7b70217a8da07 Mon Sep 17 00:00:00 2001 From: Chin Yeung Li Date: Thu, 20 Mar 2025 18:24:23 +0800 Subject: [PATCH 03/12] #468 - replace single quote with double quote to adapt ruff fomat check Signed-off-by: Chin Yeung Li --- minecode/collectors/pypi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/minecode/collectors/pypi.py b/minecode/collectors/pypi.py index 0c289f31..b165e8c9 100644 --- a/minecode/collectors/pypi.py +++ b/minecode/collectors/pypi.py @@ -117,7 +117,7 @@ def process_request(purl_str, **kwargs): # package_url.version cannot be set as it will raise # AttributeError: can't set attribute # package_url.version = version - purl = purl_str.replace('@','') + '@' + version + purl = purl_str.replace("@", "") + "@" + version package_url = PackageURL.from_string(purl) error_msg = map_pypi_package(package_url, pipelines, priority) From 28df48425493c25a50420c842d5ddc039544677b Mon Sep 17 00:00:00 2001 From: Chin Yeung Li Date: Tue, 25 Mar 2025 16:43:10 +0800 Subject: [PATCH 04/12] Fixed #468 - Create multiple purls with the qualifier when multiple packages are available for a single version. Signed-off-by: Chin Yeung Li --- minecode/collectors/pypi.py | 10 ++++++---- minecode/miners/pypi.py | 15 +++++++++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/minecode/collectors/pypi.py b/minecode/collectors/pypi.py index b165e8c9..efb6cad8 100644 --- a/minecode/collectors/pypi.py +++ b/minecode/collectors/pypi.py @@ -67,6 +67,7 @@ def map_pypi_package(package_url, pipelines, priority=0): from minecode.model_utils import add_package_to_scan_queue from minecode.model_utils import merge_or_create_package + error = "" package_json = get_package_json( name=package_url.name, version=package_url.version, @@ -80,9 +81,10 @@ def map_pypi_package(package_url, pipelines, priority=0): packages = build_packages(package_json, package_url) for package in packages: - package.extra_data["package_content"] = PackageContentType.SOURCE_ARCHIVE - + # package.extra_data["package_content"] = PackageContentType.SOURCE_ARCHIVE db_package, _, _, error = merge_or_create_package(package, visit_level=0) + if error: + break # Submit package for scanning if db_package: @@ -90,7 +92,7 @@ def map_pypi_package(package_url, pipelines, priority=0): package=db_package, pipelines=pipelines, priority=priority ) - return error + return error @priority_router.route("pkg:pypi/.*") @@ -117,7 +119,7 @@ def process_request(purl_str, **kwargs): # package_url.version cannot be set as it will raise # AttributeError: can't set attribute # package_url.version = version - purl = purl_str.replace("@", "") + "@" + version + purl = purl_str + "@" + version package_url = PackageURL.from_string(purl) error_msg = map_pypi_package(package_url, pipelines, priority) diff --git a/minecode/miners/pypi.py b/minecode/miners/pypi.py index 9669ff19..5fb22ee6 100644 --- a/minecode/miners/pypi.py +++ b/minecode/miners/pypi.py @@ -259,11 +259,17 @@ def build_packages(metadata, purl=None): if not url: continue + packagetype = None + if download.get("packagetype") == "sdist": + packagetype = "pypi_sdist_pkginfo" + else: + packagetype = "pypi_bdist_pkginfo" + download_data = dict( download_url=url, size=download.get("size"), release_date=parse_date(download.get("upload_time")), - datasource_id="pypi_sdist_pkginfo", + datasource_id=packagetype, type="pypi", ) # TODO: Check for other checksums @@ -271,5 +277,10 @@ def build_packages(metadata, purl=None): download_data.update(common_data) package = scan_models.PackageData.from_data(download_data) package.datasource_id = "pypi_api_metadata" - package.set_purl(purl) + + purl_str = purl.to_string() + purl_filename_qualifiers = purl_str + "?file_name=" + download.get("filename") + updated_purl = PackageURL.from_string(purl_filename_qualifiers) + package.set_purl(updated_purl) + yield package From 3523b31c222fa338b4f498d87c74b8cc8abce536 Mon Sep 17 00:00:00 2001 From: Chin Yeung Date: Fri, 28 Mar 2025 07:00:10 +0800 Subject: [PATCH 05/12] Update minecode/collectors/pypi.py Correct typo Signed-off-by: Chin Yeung Li Co-authored-by: Jono Yang --- minecode/collectors/pypi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/minecode/collectors/pypi.py b/minecode/collectors/pypi.py index efb6cad8..b882eb12 100644 --- a/minecode/collectors/pypi.py +++ b/minecode/collectors/pypi.py @@ -44,7 +44,7 @@ def get_package_json(name, version): def get_all_package_version(name): """ - Resturn a list of all version numbers for the package name. + Return a list of all version numbers for the package name. """ url = f"https://pypi.org/pypi/{name}/json" try: From ac8d2edb3fbd5c9e68de5dd9b0e5a7bb56e7cb6a Mon Sep 17 00:00:00 2001 From: Chin Yeung Li Date: Fri, 28 Mar 2025 07:08:46 +0800 Subject: [PATCH 06/12] #468 - Remove non-used line. Signed-off-by: Chin Yeung Li --- minecode/collectors/pypi.py | 1 - 1 file changed, 1 deletion(-) diff --git a/minecode/collectors/pypi.py b/minecode/collectors/pypi.py index b882eb12..62ec5c17 100644 --- a/minecode/collectors/pypi.py +++ b/minecode/collectors/pypi.py @@ -81,7 +81,6 @@ def map_pypi_package(package_url, pipelines, priority=0): packages = build_packages(package_json, package_url) for package in packages: - # package.extra_data["package_content"] = PackageContentType.SOURCE_ARCHIVE db_package, _, _, error = merge_or_create_package(package, visit_level=0) if error: break From f8076838edcf59ed9d9c69a54a215c5f6cc213de Mon Sep 17 00:00:00 2001 From: Chin Yeung Li Date: Fri, 28 Mar 2025 11:21:30 +0800 Subject: [PATCH 07/12] #468 - Add tests Signed-off-by: Chin Yeung Li --- minecode/tests/collectors/test_pypi.py | 63 +++++++++++++++++++ minecode/tests/testfiles/pypi/cage_1.1.4.json | 55 ++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 minecode/tests/collectors/test_pypi.py create mode 100644 minecode/tests/testfiles/pypi/cage_1.1.4.json diff --git a/minecode/tests/collectors/test_pypi.py b/minecode/tests/collectors/test_pypi.py new file mode 100644 index 00000000..520499d9 --- /dev/null +++ b/minecode/tests/collectors/test_pypi.py @@ -0,0 +1,63 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# purldb is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/nexB/purldb for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + +import json +import os + +from django.test import TestCase as DjangoTestCase + +from packageurl import PackageURL + +import packagedb +from minecode.collectors import pypi +from minecode.utils_test import JsonBasedTesting + + +class PypiPriorityQueueTests(JsonBasedTesting, DjangoTestCase): + test_data_dir = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "testfiles" + ) + + def setUp(self): + super().setUp() + self.expected_json_loc = self.get_test_loc("pypi/cage_1.1.4.json") + with open(self.expected_json_loc) as f: + self.expected_json_contents = json.load(f) + + def test_get_package_json(self): + json_contents = pypi.get_package_json( + name=self.scan_package.name, + version=self.scan_package.version, + ) + self.assertEqual(self.expected_json_contents, json_contents) + + def test_get_all_package_version(self): + releases_list = pypi.get_all_package_version("cage") + expected = ['1.1.2', '1.1.3', '1.1.4'] + # At the time of creating this test, the CAGE project has three + # releases. There may be additional releases in the future. + # Therefore, we will verify that the number of releases is three + # or greater and that it includes the expected release versions. + self.assertTrue(len(releases_list) >= 3) + for version in expected: + self.assertIn(version, releases_list) + + def test_map_npm_package(self): + package_count = packagedb.models.Package.objects.all().count() + self.assertEqual(0, package_count) + package_url = PackageURL.from_string("pkg:pypi/cage@1.1.4") + pypi.map_pypi_package(package_url, ("test_pipeline")) + package_count = packagedb.models.Package.objects.all().count() + self.assertEqual(1, package_count) + package = packagedb.models.Package.objects.all().first() + expected_purl_str = "pkg:pypi/cage@1.1.4" + expected_download_url = "http://www.alcyone.com/software/cage/cage-latest.tar.gz" + self.assertEqual(expected_purl_str, package.purl) + self.assertEqual(expected_download_url, package.download_url) + diff --git a/minecode/tests/testfiles/pypi/cage_1.1.4.json b/minecode/tests/testfiles/pypi/cage_1.1.4.json new file mode 100644 index 00000000..7ab97aae --- /dev/null +++ b/minecode/tests/testfiles/pypi/cage_1.1.4.json @@ -0,0 +1,55 @@ +{ + "info": { + "author": "Erik Max Francis", + "author_email": "software@alcyone.com", + "bugtrack_url": null, + "classifiers": [ + "Development Status :: 6 - Mature", + "Intended Audience :: Developers", + "Intended Audience :: End Users/Desktop", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: GNU General Public License (GPL)", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Topic :: Games/Entertainment", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Scientific/Engineering :: Mathematics" + ], + "description": "CAGE is a fairy generic and complete cellular automaton simulation\r\n engine in Python. It supports both 1D and 2D automata, a variety\r\n of prepackaged rules, and the concept of \"agents\" which can move\r\n about independently on the map for implementing agent behavior.\r\n\r\n CAGE comes with numerous examples of fully-functional CA systems,\r\n including Conway's Game of Life, Langton's self-reproducing\r\n automaton, Langton's \"vants,\" and 1D automata rule explorers. It\r\n also comes with simple displayers (including a curses interface\r\n for 2D automata). Also included is a unique implementation of a\r\n finite state machine (ant.py).", + "description_content_type": null, + "docs_url": null, + "download_url": "http://www.alcyone.com/software/cage/cage-latest.tar.gz", + "downloads": { + "last_day": -1, + "last_month": -1, + "last_week": -1 + }, + "dynamic": null, + "home_page": "http://www.alcyone.com/software/cage/", + "keywords": "cellular automata, Turing machines, Langton vants, self-organizing systems, finite state machines, finite state automata", + "license": "GPL", + "license_expression": null, + "license_files": null, + "maintainer": "", + "maintainer_email": "", + "name": "CAGE", + "package_url": "https://pypi.org/project/CAGE/", + "platform": "any; Unix for curses frontend", + "project_url": "https://pypi.org/project/CAGE/", + "project_urls": { + "Download": "http://www.alcyone.com/software/cage/cage-latest.tar.gz", + "Homepage": "http://www.alcyone.com/software/cage/" + }, + "provides_extra": null, + "release_url": "https://pypi.org/project/CAGE/1.1.4/", + "requires_dist": null, + "requires_python": null, + "summary": "A generic and fairly complete cellular automata simulation engine.", + "version": "1.1.4", + "yanked": false, + "yanked_reason": null + }, + "last_serial": 944145, + "urls": [], + "vulnerabilities": [] +} From a7835adeebe817b177b9fb900bda88f097affd67 Mon Sep 17 00:00:00 2001 From: Chin Yeung Li Date: Fri, 28 Mar 2025 11:30:01 +0800 Subject: [PATCH 08/12] #468 - replace self.scan_package with actual package name and version Signed-off-by: Chin Yeung Li --- minecode/tests/collectors/test_pypi.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/minecode/tests/collectors/test_pypi.py b/minecode/tests/collectors/test_pypi.py index 520499d9..1707a161 100644 --- a/minecode/tests/collectors/test_pypi.py +++ b/minecode/tests/collectors/test_pypi.py @@ -30,10 +30,11 @@ def setUp(self): with open(self.expected_json_loc) as f: self.expected_json_contents = json.load(f) + def test_get_package_json(self): json_contents = pypi.get_package_json( - name=self.scan_package.name, - version=self.scan_package.version, + name="cage", + version="1.1.4", ) self.assertEqual(self.expected_json_contents, json_contents) From 27fead546c222e41908e541dfaee79aaacb32844 Mon Sep 17 00:00:00 2001 From: Chin Yeung Li Date: Mon, 31 Mar 2025 10:24:43 +0800 Subject: [PATCH 09/12] #468 - Fix the tests Signed-off-by: Chin Yeung Li --- minecode/miners/pypi.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/minecode/miners/pypi.py b/minecode/miners/pypi.py index 5fb22ee6..edcde7a4 100644 --- a/minecode/miners/pypi.py +++ b/minecode/miners/pypi.py @@ -259,11 +259,10 @@ def build_packages(metadata, purl=None): if not url: continue - packagetype = None - if download.get("packagetype") == "sdist": - packagetype = "pypi_sdist_pkginfo" - else: - packagetype = "pypi_bdist_pkginfo" + packagetype = "pypi_sdist_pkginfo" + if "packagetype" in download: + if download.get("packagetype") == "bdist_wheel": + packagetype = "pypi_bdist_pkginfo" download_data = dict( download_url=url, From b6f8a5ad8c444823f6380f60774993f8315662d9 Mon Sep 17 00:00:00 2001 From: Chin Yeung Li Date: Mon, 31 Mar 2025 11:43:20 +0800 Subject: [PATCH 10/12] #468 - Fix tests Signed-off-by: Chin Yeung Li --- minecode/miners/pypi.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/minecode/miners/pypi.py b/minecode/miners/pypi.py index edcde7a4..b830609d 100644 --- a/minecode/miners/pypi.py +++ b/minecode/miners/pypi.py @@ -259,10 +259,11 @@ def build_packages(metadata, purl=None): if not url: continue - packagetype = "pypi_sdist_pkginfo" - if "packagetype" in download: - if download.get("packagetype") == "bdist_wheel": - packagetype = "pypi_bdist_pkginfo" + packagetype = None + if download.get("packagetype") == "sdist": + packagetype = "pypi_sdist_pkginfo" + else: + packagetype = "pypi_bdist_pkginfo" download_data = dict( download_url=url, @@ -277,9 +278,12 @@ def build_packages(metadata, purl=None): package = scan_models.PackageData.from_data(download_data) package.datasource_id = "pypi_api_metadata" - purl_str = purl.to_string() - purl_filename_qualifiers = purl_str + "?file_name=" + download.get("filename") - updated_purl = PackageURL.from_string(purl_filename_qualifiers) - package.set_purl(updated_purl) + if purl: + purl_str = purl.to_string() + purl_filename_qualifiers = purl_str + "?file_name=" + download.get("filename") + updated_purl = PackageURL.from_string(purl_filename_qualifiers) + package.set_purl(updated_purl) + else: + package.set_purl(purl) yield package From e820f494e05bec51f21736478b6cb1ae5a92e13e Mon Sep 17 00:00:00 2001 From: Chin Yeung Li Date: Mon, 31 Mar 2025 12:27:14 +0800 Subject: [PATCH 11/12] #468 - Removed unused import Signed-off-by: Chin Yeung Li --- minecode/collectors/pypi.py | 1 - 1 file changed, 1 deletion(-) diff --git a/minecode/collectors/pypi.py b/minecode/collectors/pypi.py index 62ec5c17..0abb062c 100644 --- a/minecode/collectors/pypi.py +++ b/minecode/collectors/pypi.py @@ -14,7 +14,6 @@ from minecode import priority_router from minecode.miners.pypi import build_packages -from packagedb.models import PackageContentType """ Collect PyPI packages from pypi registries. From 40ed549df48af43cd63e0e18500191f0f4ad14a6 Mon Sep 17 00:00:00 2001 From: Chin Yeung Li Date: Mon, 31 Mar 2025 12:57:29 +0800 Subject: [PATCH 12/12] #468 - Applied Ruff format Signed-off-by: Chin Yeung Li --- minecode/miners/pypi.py | 4 +++- minecode/tests/collectors/test_pypi.py | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/minecode/miners/pypi.py b/minecode/miners/pypi.py index b830609d..977f8333 100644 --- a/minecode/miners/pypi.py +++ b/minecode/miners/pypi.py @@ -280,7 +280,9 @@ def build_packages(metadata, purl=None): if purl: purl_str = purl.to_string() - purl_filename_qualifiers = purl_str + "?file_name=" + download.get("filename") + purl_filename_qualifiers = ( + purl_str + "?file_name=" + download.get("filename") + ) updated_purl = PackageURL.from_string(purl_filename_qualifiers) package.set_purl(updated_purl) else: diff --git a/minecode/tests/collectors/test_pypi.py b/minecode/tests/collectors/test_pypi.py index 1707a161..be5678df 100644 --- a/minecode/tests/collectors/test_pypi.py +++ b/minecode/tests/collectors/test_pypi.py @@ -30,7 +30,6 @@ def setUp(self): with open(self.expected_json_loc) as f: self.expected_json_contents = json.load(f) - def test_get_package_json(self): json_contents = pypi.get_package_json( name="cage", @@ -40,7 +39,7 @@ def test_get_package_json(self): def test_get_all_package_version(self): releases_list = pypi.get_all_package_version("cage") - expected = ['1.1.2', '1.1.3', '1.1.4'] + expected = ["1.1.2", "1.1.3", "1.1.4"] # At the time of creating this test, the CAGE project has three # releases. There may be additional releases in the future. # Therefore, we will verify that the number of releases is three @@ -58,7 +57,8 @@ def test_map_npm_package(self): self.assertEqual(1, package_count) package = packagedb.models.Package.objects.all().first() expected_purl_str = "pkg:pypi/cage@1.1.4" - expected_download_url = "http://www.alcyone.com/software/cage/cage-latest.tar.gz" + expected_download_url = ( + "http://www.alcyone.com/software/cage/cage-latest.tar.gz" + ) self.assertEqual(expected_purl_str, package.purl) self.assertEqual(expected_download_url, package.download_url) -