Skip to content

Commit d0422b5

Browse files
authored
Merge pull request #154 from aboutcode-org/add_pypi_download_url_support
Add Pypi download URL support
2 parents 3090030 + 6e9792c commit d0422b5

File tree

5 files changed

+190
-15
lines changed

5 files changed

+190
-15
lines changed

azure-pipelines.yml

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,6 @@
55
################################################################################
66

77
jobs:
8-
- template: etc/ci/azure-posix.yml
9-
parameters:
10-
job_name: ubuntu20_cpython
11-
image_name: ubuntu-20.04
12-
python_versions: ['3.8', '3.9', '3.10', '3.11', '3.12']
13-
test_suites:
14-
all: venv/bin/pytest -n 2 -vvs
158

169
- template: etc/ci/azure-posix.yml
1710
parameters:
@@ -21,14 +14,6 @@ jobs:
2114
test_suites:
2215
all: venv/bin/pytest -n 2 -vvs
2316

24-
- template: etc/ci/azure-posix.yml
25-
parameters:
26-
job_name: macos12_cpython
27-
image_name: macOS-12
28-
python_versions: ['3.8', '3.9', '3.10', '3.11', '3.12']
29-
test_suites:
30-
all: venv/bin/pytest -n 2 -vvs
31-
3217
- template: etc/ci/azure-posix.yml
3318
parameters:
3419
job_name: macos13_cpython

src/fetchcode/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def fetch_http(url, location):
4444
`url` URL string saving the content in a file at `location`
4545
"""
4646
r = requests.get(url)
47+
4748
with open(location, "wb") as f:
4849
f.write(r.content)
4950

@@ -106,3 +107,17 @@ def fetch(url):
106107
return fetchers.get(scheme)(url, location)
107108

108109
raise Exception("Not a supported/known scheme.")
110+
111+
112+
def fetch_json_response(url):
113+
"""
114+
Fetch a JSON response from the given URL and return the parsed JSON data.
115+
"""
116+
response = requests.get(url)
117+
if response.status_code != 200:
118+
raise Exception(f"Failed to fetch {url}: {response.status_code} {response.reason}")
119+
120+
try:
121+
return response.json()
122+
except ValueError as e:
123+
raise Exception(f"Failed to parse JSON from {url}: {str(e)}")

src/fetchcode/download_urls.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# fetchcode is a free software tool from nexB Inc. and others.
2+
# Visit https://github.com/aboutcode-org/fetchcode for support and download.
3+
#
4+
# Copyright (c) nexB Inc. and others. All rights reserved.
5+
# http://nexb.com and http://aboutcode.org
6+
#
7+
# This software is licensed under the Apache License version 2.0.
8+
#
9+
# You may not use this software except in compliance with the License.
10+
# You may obtain a copy of the License at:
11+
# http://apache.org/licenses/LICENSE-2.0
12+
# Unless required by applicable law or agreed to in writing, software distributed
13+
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
14+
# CONDITIONS OF ANY KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations under the License.
16+
17+
from packageurl.contrib.route import NoRouteAvailable
18+
from packageurl.contrib.route import Router
19+
20+
from fetchcode.pypi import Pypi
21+
22+
package_registry = [
23+
Pypi,
24+
]
25+
26+
router = Router()
27+
28+
for pkg_class in package_registry:
29+
router.append(pattern=pkg_class.purl_pattern, endpoint=pkg_class.get_download_url)
30+
31+
32+
def download_url(purl):
33+
"""
34+
Return package metadata for a URL or PURL.
35+
Return None if there is no URL, or the URL or PURL is not supported.
36+
"""
37+
if purl:
38+
try:
39+
return router.process(purl)
40+
except NoRouteAvailable:
41+
return

src/fetchcode/pypi.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# fetchcode is a free software tool from nexB Inc. and others.
2+
# Visit https://github.com/aboutcode-org/fetchcode for support and download.
3+
#
4+
# Copyright (c) nexB Inc. and others. All rights reserved.
5+
# http://nexb.com and http://aboutcode.org
6+
#
7+
# This software is licensed under the Apache License version 2.0.
8+
#
9+
# You may not use this software except in compliance with the License.
10+
# You may obtain a copy of the License at:
11+
# http://apache.org/licenses/LICENSE-2.0
12+
# Unless required by applicable law or agreed to in writing, software distributed
13+
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
14+
# CONDITIONS OF ANY KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations under the License.
16+
17+
from urllib.parse import urljoin
18+
19+
from packageurl import PackageURL
20+
21+
from fetchcode import fetch_json_response
22+
23+
24+
class Pypi:
25+
"""
26+
This class handles Cargo PURLs.
27+
"""
28+
29+
purl_pattern = "pkg:pypi/.*"
30+
base_url = "https://pypi.org/pypi/"
31+
32+
@classmethod
33+
def get_download_url(cls, purl):
34+
"""
35+
Return the download URL for a Pypi PURL.
36+
"""
37+
purl = PackageURL.from_string(purl)
38+
39+
name = purl.name
40+
version = purl.version
41+
42+
if not name or not version:
43+
raise ValueError("Pypi PURL must specify a name and version")
44+
45+
url = urljoin(cls.base_url, f"{name}/{version}/json")
46+
data = fetch_json_response(url)
47+
48+
download_urls = data.get("urls", [{}])
49+
50+
if not download_urls:
51+
raise ValueError(f"No download URLs found for {name} version {version}")
52+
53+
download_url = next((url["url"] for url in download_urls if url.get("url")), None)
54+
55+
if not download_url:
56+
raise ValueError(f"No download URL found for {name} version {version}")
57+
58+
return download_url

tests/test_pypi.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import unittest
2+
from unittest.mock import patch
3+
4+
from fetchcode.pypi import Pypi
5+
6+
7+
class TestGetDownloadURL(unittest.TestCase):
8+
@patch("fetchcode.pypi.fetch_json_response")
9+
def test_valid_purl_returns_download_url(self, mock_fetch_json_response):
10+
mock_response = {
11+
"urls": [
12+
{
13+
"url": "https://files.pythonhosted.org/packages/source/r/requests/requests-2.31.0.tar.gz"
14+
}
15+
]
16+
}
17+
mock_fetch_json_response.return_value = mock_response
18+
19+
purl = "pkg:pypi/[email protected]"
20+
result = Pypi.get_download_url(purl)
21+
self.assertEqual(
22+
result,
23+
"https://files.pythonhosted.org/packages/source/r/requests/requests-2.31.0.tar.gz",
24+
)
25+
26+
@patch("fetchcode.pypi.fetch_json_response")
27+
def test_missing_version_raises_value_error(self, mock_fetch_json_response):
28+
purl = "pkg:pypi/requests"
29+
with self.assertRaises(ValueError) as context:
30+
Pypi.get_download_url(purl)
31+
self.assertIn("Pypi PURL must specify a name and version", str(context.exception))
32+
33+
@patch("fetchcode.pypi.fetch_json_response")
34+
def test_missing_name_raises_value_error(self, mock_fetch_json_response):
35+
purl = "pkg:pypi/@2.31.0"
36+
with self.assertRaises(ValueError) as context:
37+
Pypi.get_download_url(purl)
38+
self.assertIn("purl is missing the required name component", str(context.exception))
39+
40+
@patch("fetchcode.pypi.fetch_json_response")
41+
def test_missing_urls_field_raises_value_error(self, mock_fetch_json_response):
42+
mock_fetch_json_response.return_value = {}
43+
purl = "pkg:pypi/[email protected]"
44+
with self.assertRaises(ValueError) as context:
45+
Pypi.get_download_url(purl)
46+
self.assertIn("No download URL found", str(context.exception))
47+
48+
@patch("fetchcode.pypi.fetch_json_response")
49+
def test_empty_urls_list_raises_value_error(self, mock_fetch_json_response):
50+
mock_fetch_json_response.return_value = {"urls": []}
51+
purl = "pkg:pypi/[email protected]"
52+
with self.assertRaises(ValueError) as context:
53+
Pypi.get_download_url(purl)
54+
self.assertIn("No download URLs found", str(context.exception))
55+
56+
@patch("fetchcode.pypi.fetch_json_response")
57+
def test_first_url_object_missing_url_key(self, mock_fetch_json_response):
58+
mock_fetch_json_response.return_value = {"urls": [{}]}
59+
purl = "pkg:pypi/[email protected]"
60+
with self.assertRaises(ValueError) as context:
61+
Pypi.get_download_url(purl)
62+
self.assertIn("No download URL found", str(context.exception))
63+
64+
@patch("fetchcode.pypi.fetch_json_response")
65+
def test_url_fallback_when_multiple_urls_provided(self, mock_fetch_json_response):
66+
mock_fetch_json_response.return_value = {
67+
"urls": [{}, {"url": "https://example.com/fallback-url.tar.gz"}]
68+
}
69+
70+
purl = "pkg:pypi/[email protected]"
71+
download_url = Pypi.get_download_url(purl)
72+
self.assertEqual(download_url, "https://example.com/fallback-url.tar.gz")
73+
74+
def test_malformed_purl_raises_exception(self):
75+
with self.assertRaises(ValueError):
76+
Pypi.get_download_url("this-is-not-a-valid-purl")

0 commit comments

Comments
 (0)