diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b8288c7..bd906c3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,12 @@ Changelog ========= +0.17.2 (2025-07-29) +------------------- + +- Add support for getting download URL for Golang, Hex, Pub and Swift in ``purl2url``. + https://github.com/package-url/packageurl-python/pull/195 + 0.17.1 (2025-06-06) ------------------- diff --git a/setup.cfg b/setup.cfg index 1391450..1c187d6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = packageurl-python -version = 0.17.1 +version = 0.17.2 license = MIT description = A purl aka. Package URL parser and builder long_description = file:README.rst diff --git a/src/packageurl/__init__.py b/src/packageurl/__init__.py index d1e6837..2b7e052 100644 --- a/src/packageurl/__init__.py +++ b/src/packageurl/__init__.py @@ -72,7 +72,7 @@ def unquote(s: AnyStr) -> str: Return a percent-decoded unicode string, given an `s` byte or unicode string. """ - unquoted = _percent_unquote(s) # type:ignore[arg-type] # typeshed is incorrect here + unquoted = _percent_unquote(s) if not isinstance(unquoted, str): unquoted = unquoted.decode("utf-8") return unquoted diff --git a/src/packageurl/contrib/purl2url.py b/src/packageurl/contrib/purl2url.py index a2dfff2..6926150 100644 --- a/src/packageurl/contrib/purl2url.py +++ b/src/packageurl/contrib/purl2url.py @@ -443,6 +443,76 @@ def build_repo_download_url(purl): return get_repo_download_url(purl) +@download_router.route("pkg:hex/.*") +def build_hex_download_url(purl): + """ + Return a hex download URL from the `purl` string. + """ + purl_data = PackageURL.from_string(purl) + + name = purl_data.name + version = purl_data.version + + if name and version: + return f"https://repo.hex.pm/tarballs/{name}-{version}.tar" + + +@download_router.route("pkg:golang/.*") +def build_golang_download_url(purl): + """ + Return a golang download URL from the `purl` string. + """ + purl_data = PackageURL.from_string(purl) + + namespace = purl_data.namespace + name = purl_data.name + version = purl_data.version + + if not name: + return + + # TODO: https://github.com/package-url/packageurl-python/issues/197 + if namespace: + name = f"{namespace}/{name}" + + ename = escape_golang_path(name) + eversion = escape_golang_path(version) + + if name and version: + return f"https://proxy.golang.org/{ename}/@v/{eversion}.zip" + + +@download_router.route("pkg:pub/.*") +def build_pub_download_url(purl): + """ + Return a pub download URL from the `purl` string. + """ + purl_data = PackageURL.from_string(purl) + + name = purl_data.name + version = purl_data.version + + if name and version: + return f"https://pub.dev/api/archives/{name}-{version}.tar.gz" + + +@download_router.route("pkg:swift/.*") +def build_swift_download_url(purl): + """ + Return a Swift Package download URL from the `purl` string. + """ + purl_data = PackageURL.from_string(purl) + + name = purl_data.name + version = purl_data.version + namespace = purl_data.namespace + + if not (namespace or name or version): + return + + return f"https://{namespace}/{name}/archive/{version}.zip" + + def get_repo_download_url(purl): """ Return ``download_url`` if present in ``purl`` qualifiers or @@ -470,3 +540,24 @@ def get_repo_download_url(purl): return get_repo_download_url_by_package_type( type=type, namespace=namespace, name=name, version=version ) + + +# TODO: https://github.com/package-url/packageurl-python/issues/196 +def escape_golang_path(path: str) -> str: + """ + Return an case-encoded module path or version name. + + This is done by replacing every uppercase letter with an exclamation mark followed by the + corresponding lower-case letter, in order to avoid ambiguity when serving from case-insensitive + file systems. + + See https://golang.org/ref/mod#goproxy-protocol. + """ + escaped_path = "" + for c in path: + if c >= "A" and c <= "Z": + # replace uppercase with !lowercase + escaped_path += "!" + chr(ord(c) + ord("a") - ord("A")) + else: + escaped_path += c + return escaped_path diff --git a/tests/contrib/test_purl2url.py b/tests/contrib/test_purl2url.py index fee98d4..52d1ec3 100644 --- a/tests/contrib/test_purl2url.py +++ b/tests/contrib/test_purl2url.py @@ -98,6 +98,13 @@ def test_purl2url_get_download_url(): "pkg:maven/org.apache.commons/commons-io@1.3.2?repository_url=https://repo1.maven.org/maven2": "https://repo1.maven.org/maven2/org/apache/commons/commons-io/1.3.2/commons-io-1.3.2.jar", "pkg:maven/org.apache.commons/commons-io@1.3.2?type=pom": "https://repo.maven.apache.org/maven2/org/apache/commons/commons-io/1.3.2/commons-io-1.3.2.pom", "pkg:maven/org.apache.commons/commons-math3@3.6.1?classifier=sources": "https://repo.maven.apache.org/maven2/org/apache/commons/commons-math3/3.6.1/commons-math3-3.6.1-sources.jar", + "pkg:hex/plug@1.11.1": "https://repo.hex.pm/tarballs/plug-1.11.1.tar", + "pkg:golang/xorm.io/xorm@v0.8.2": "https://proxy.golang.org/xorm.io/xorm/@v/v0.8.2.zip", + "pkg:golang/gopkg.in/ldap.v3@v3.1.0": "https://proxy.golang.org/gopkg.in/ldap.v3/@v/v3.1.0.zip", + "pkg:golang/example.com/M.v3@v3.1.0": "https://proxy.golang.org/example.com/!m.v3/@v/v3.1.0.zip", + "pkg:pub/http@0.13.3": "https://pub.dev/api/archives/http-0.13.3.tar.gz", + "pkg:swift/github.com/Alamofire/Alamofire@5.4.3": "https://github.com/Alamofire/Alamofire/archive/5.4.3.zip", + "pkg:swift/github.com/RxSwiftCommunity/RxFlow@2.12.4": "https://github.com/RxSwiftCommunity/RxFlow/archive/2.12.4.zip", # 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", @@ -112,8 +119,6 @@ def test_purl2url_get_download_url(): "pkg:bitbucket/birkenfeld": None, "pkg:pypi/sortedcontainers@2.4.0": None, "pkg:composer/psr/log@1.1.3": None, - "pkg:golang/xorm.io/xorm@v0.8.2": None, - "pkg:golang/gopkg.in/ldap.v3@v3.1.0": None, } for purl, url in purls_url.items():