diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 286f9f8..75a8870 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,12 @@ Changelog ========= +0.17.4 (2025-08-05) +------------------- + +- Add support for getting download URL for debian, apk, qpkg in ``purl2url``. + https://github.com/package-url/packageurl-python/pull/201 + 0.17.3 (2025-08-01) ------------------- diff --git a/setup.cfg b/setup.cfg index b11d189..fa7bae4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = packageurl-python -version = 0.17.3 +version = 0.17.4 license = MIT description = A purl aka. Package URL parser and builder long_description = file:README.rst diff --git a/src/packageurl/contrib/purl2url.py b/src/packageurl/contrib/purl2url.py index 6c0a7a0..a3d3f83 100644 --- a/src/packageurl/contrib/purl2url.py +++ b/src/packageurl/contrib/purl2url.py @@ -593,6 +593,100 @@ def build_alpm_download_url(purl_str): return url +def normalize_version(version: str) -> str: + """ + Remove the epoch (if any) from a Debian version. + E.g., "1:2.4.47-2" becomes "2.4.47-2" + """ + if ":" in version: + _, v = version.split(":", 1) + return v + return version + + +@download_router.route("pkg:deb/.*") +def build_deb_download_url(purl_str: str) -> str: + """ + Construct a download URL for a Debian or Ubuntu package PURL. + Supports optional 'repository_url' in qualifiers. + """ + p = PackageURL.from_string(purl_str) + + name = p.name + version = p.version + namespace = p.namespace + qualifiers = p.qualifiers or {} + arch = qualifiers.get("arch") + repository_url = qualifiers.get("repository_url") + + if not name or not version: + raise ValueError("Both name and version must be present in deb purl") + + if not arch: + arch = "source" + + if repository_url: + base_url = repository_url.rstrip("/") + else: + if namespace == "debian": + base_url = "https://deb.debian.org/debian" + elif namespace == "ubuntu": + base_url = "http://archive.ubuntu.com/ubuntu" + else: + raise NotImplementedError(f"Unsupported distro namespace: {namespace}") + + norm_version = normalize_version(version) + + if arch == "source": + filename = f"{name}_{norm_version}.dsc" + else: + filename = f"{name}_{norm_version}_{arch}.deb" + + pool_path = f"/pool/main/{name[0].lower()}/{name}" + + return f"{base_url}{pool_path}/{filename}" + + +@download_router.route("pkg:qpkg/.*") +def build_qpkg_download_url(purl: str) -> str: + purl = PackageURL.from_string(purl) + repo_url = purl.qualifiers.get("repo_url") + + if not repo_url: + raise ValueError("repository_url qualifier is required for qpkg purl resolution") + + if not purl.namespace or not purl.name or not purl.version: + raise ValueError("namespace, name, and version must be present in qpkg purl") + + path = f"{purl.namespace}/{purl.name}/{purl.version}.qpkg" + return f"{repo_url.rstrip('/')}/{path}" + + +@download_router.route("pkg:apk/.*") +def build_apk_download_url(purl): + """ + Return a download URL for a fully qualified Alpine Linux package PURL. + + Example: + pkg:apk/acct@6.6.4-r0?arch=x86&alpine_version=v3.11&repo=main + """ + purl = PackageURL.from_string(purl) + name = purl.name + version = purl.version + arch = purl.qualifiers.get("arch") + repo = purl.qualifiers.get("repo") + alpine_version = purl.qualifiers.get("alpine_version") + + if not name or not version or not arch or not repo or not alpine_version: + raise ValueError( + "All qualifiers (arch, repo, alpine_version) and name/version must be present in apk purl" + ) + + return ( + f"https://dl-cdn.alpinelinux.org/alpine/{alpine_version}/{repo}/{arch}/{name}-{version}.apk" + ) + + def get_repo_download_url(purl): """ Return ``download_url`` if present in ``purl`` qualifiers or diff --git a/tests/contrib/test_purl2url.py b/tests/contrib/test_purl2url.py index 0b318a3..2269628 100644 --- a/tests/contrib/test_purl2url.py +++ b/tests/contrib/test_purl2url.py @@ -110,6 +110,10 @@ def test_purl2url_get_download_url(): "pkg:luarocks/hisham/luafilesystem@1.8.0-1": "https://luarocks.org/luafilesystem-1.8.0-1.src.rock", "pkg:conda/absl-py@0.4.1?build=py36h06a4308_0&channel=main&subdir=linux-64&type=tar.bz2": "https://repo.anaconda.com/pkgs/main/linux-64/absl-py-0.4.1-py36h06a4308_0.tar.bz2", "pkg:alpm/arch/pacman@6.0.1-1?arch=x86_64": "https://archive.archlinux.org/packages/p/pacman/pacman-6.0.1-1-x86_64.pkg.tar.zst", + "pkg:deb/debian/attr@1:2.4.48-6?arch=amd64": "https://deb.debian.org/debian/pool/main/a/attr/attr_2.4.48-6_amd64.deb", + "pkg:deb/debian/attr@1:2.4.48-6?arch=amd64&repository_url=http://archive.debian.org/debian": "http://archive.debian.org/debian/pool/main/a/attr/attr_2.4.48-6_amd64.deb", + "pkg:qpkg/blackberry/com.qnx.sdp@7.0.0.SGA201702151847?repo_url=http://repo.blackberry.com": "http://repo.blackberry.com/blackberry/com.qnx.sdp/7.0.0.SGA201702151847.qpkg", + "pkg:apk/acct@6.6.4-r0?arch=x86&alpine_version=v3.11&repo=main": "https://dl-cdn.alpinelinux.org/alpine/v3.11/main/x86/acct-6.6.4-r0.apk", # From `download_url` qualifier "pkg:github/yarnpkg/yarn@1.3.2?download_url=https://github.com/yarnpkg/yarn/releases/download/v1.3.2/yarn-v1.3.2.tar.gz&version_prefix=v": "https://github.com/yarnpkg/yarn/releases/download/v1.3.2/yarn-v1.3.2.tar.gz", "pkg:generic/lxc-master.tar.gz?download_url=https://salsa.debian.org/lxc-team/lxc/-/archive/master/lxc-master.tar.gz": "https://salsa.debian.org/lxc-team/lxc/-/archive/master/lxc-master.tar.gz",