Repository: package-url/packageurl-python

Issue:
Feature request: purl2url for Maven
I would like to suggest that purl2url for Maven packages would be a great feature. [DejaCode](https://github.com/aboutcode-org/dejacode) and [ScanCode.io](https://github.com/aboutcode-org/scancode.io) would benefit from this, as they could then perform automatic scans for license and copyright information. Currently scans for Maven do not seem to work as they rely on packageurl-python for converting PURLs to URLs, as seen here:

- https://github.com/aboutcode-org/dejacode/blob/a28d351b5fcdd72901c35a639622aaac2d8b2445/component_catalog/models.py#L1959
- https://github.com/aboutcode-org/dejacode/blob/a28d351b5fcdd72901c35a639622aaac2d8b2445/component_catalog/views.py#L1844

Only the reverse direction url2purl for Maven seems to be currently implemented in packageurl-python.

Discussion:
@robertguetzkow: I've just noticed that there is already an open pull request for this feature, so I would very much appreciate if this could be reviewed and potentially merged. https://github.com/package-url/packageurl-python/pull/177@pombredanne: @robertguetzkow Thanks. The only reason why this may have not been implemented for Maven is the many different types of artefacts for the Maven "coordinates". There is some code in https://github.com/aboutcode-org/purldb/blob/main/minecode/collectors/maven.py too for that, but there is no reason not have it here. Actually this is a must!@robertguetzkow: That is a good point, there could be various file types behind it. Sometimes that seems to be indicated through a parameter such as `?type=jar` , but this does not have to be present in the PURL. In that case you would need the kind of logic you have linked to resolve it.@tdruez: Maven support added in https://github.com/package-url/packageurl-python/pull/177 thanks to @fafanoulele !

Few examples from the tests:

### `get_repo_url`
```
>>> from packageurl.contrib import purl2url

>>> purl2url.get_repo_url("pkg:maven/org.apache.commons/commons-io@1.3.2")
"https://repo1.maven.org/maven2/org/apache/commons/commons-io/1.3.2"
```

### `get_download_url`
```
>>> from packageurl.contrib import purl2url

>>> purl = "pkg:maven/org.apache.commons/commons-io@1.3.2"
>>> purl2url.get_download_url(purl)
"https://repo.maven.apache.org/maven2/org/apache/commons/commons-io/1.3.2/commons-io-1.3.2.jar"

>>> purl = "pkg:maven/org.apache.commons/commons-io@1.3.2?repository_url=https://repo1.maven.org/maven2"
>>> purl2url.get_download_url(purl)
"https://repo1.maven.org/maven2/org/apache/commons/commons-io/1.3.2/commons-io-1.3.2.jar"

>>> purl = "pkg:maven/org.apache.commons/commons-io@1.3.2?type=pom"
>>> purl2url.get_download_url(purl)
"https://repo.maven.apache.org/maven2/org/apache/commons/commons-io/1.3.2/commons-io-1.3.2.pom"

>>> purl = "pkg:maven/org.apache.commons/commons-math3@3.6.1?classifier=sources"
>>> purl2url.get_download_url(purl)
"https://repo.maven.apache.org/maven2/org/apache/commons/commons-math3/3.6.1/commons-math3-3.6.1-sources.jar"
```

Solution patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 05e6669..e6e706c 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,6 +1,16 @@
 Changelog
 =========
 
+0.17.0 (2025-06-04)
+-------------------
+
+- Fix qualifiers type annotations
+  https://github.com/package-url/packageurl-python/issues/169
+
+- Fix parsing of names and namespaces with colons
+  https://github.com/package-url/packageurl-python/issues/152
+
+
 0.16.0 (2024-10-22)
 -------------------
 
diff --git a/setup.cfg b/setup.cfg
index 668d254..39b73db 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = packageurl-python
-version = 0.16.0
+version = 0.17.0
 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 8199e39..9677b73 100644
--- a/src/packageurl/__init__.py
+++ b/src/packageurl/__init__.py
@@ -459,12 +459,17 @@ class PackageURL(
             url=remainder, scheme="", allow_fragments=True
         )
 
-        if scheme or authority:
-            msg = (
-                f'Invalid purl {purl!r} cannot contain a "user:pass@host:port" '
-                f"URL Authority component: {authority!r}."
-            )
-            raise ValueError(msg)
+        # The spec (seems) to allow colons in the name and namespace.
+        # urllib.urlsplit splits on : considers them parts of scheme
+        # and authority.
+        # Other libraries do not care about this.
+        # See https://github.com/package-url/packageurl-python/issues/152#issuecomment-2637692538
+        # We do + ":" + to put the colon back that urlsplit removed.
+        if authority:
+            path = authority + ":" + path
+
+        if scheme:
+            path = scheme + ":" + path
 
         path = path.lstrip("/")
 
diff --git a/src/packageurl/contrib/purl2url.py b/src/packageurl/contrib/purl2url.py
index a30780e..a2dfff2 100644
--- a/src/packageurl/contrib/purl2url.py
+++ b/src/packageurl/contrib/purl2url.py
@@ -28,6 +28,8 @@ from packageurl import PackageURL
 from packageurl.contrib.route import NoRouteAvailable
 from packageurl.contrib.route import Router
 
+DEFAULT_MAVEN_REPOSITORY = "https://repo.maven.apache.org/maven2"
+
 
 def get_repo_download_url_by_package_type(
     type, namespace, name, version, archive_extension="tar.gz"
@@ -314,6 +316,24 @@ def build_cocoapods_repo_url(purl):
     return name and f"https://cocoapods.org/pods/{name}"
 
 
+@repo_router.route("pkg:maven/.*")
+def build_maven_repo_url(purl):
+    """
+    Return a Maven repo URL from the `purl` string.
+    """
+    purl_data = PackageURL.from_string(purl)
+    namespace = purl_data.namespace
+    name = purl_data.name
+    version = purl_data.version
+    qualifiers = purl_data.qualifiers
+
+    base_url = qualifiers.get("repository_url", DEFAULT_MAVEN_REPOSITORY)
+
+    if namespace and name and version:
+        namespace = namespace.replace(".", "/")
+        return f"{base_url}/{namespace}/{name}/{version}"
+
+
 # Download URLs:
 
 
@@ -365,6 +385,28 @@ def build_npm_download_url(purl):
         return f"{base_url}/{name}/-/{name}-{version}.tgz"
 
 
+@download_router.route("pkg:maven/.*")
+def build_maven_download_url(purl):
+    """
+    Return a maven download URL from the `purl` string.
+    """
+    purl_data = PackageURL.from_string(purl)
+
+    namespace = purl_data.namespace
+    name = purl_data.name
+    version = purl_data.version
+    qualifiers = purl_data.qualifiers
+
+    base_url = qualifiers.get("repository_url", DEFAULT_MAVEN_REPOSITORY)
+    maven_type = qualifiers.get("type", "jar")  # default to "jar"
+    classifier = qualifiers.get("classifier")
+
+    if namespace and name and version:
+        namespace = namespace.replace(".", "/")
+        classifier = f"-{classifier}" if classifier else ""
+        return f"{base_url}/{namespace}/{name}/{version}/{name}-{version}{classifier}.{maven_type}"
+
+
 @download_router.route("pkg:hackage/.*")
 def build_hackage_download_url(purl):
     """


Test patch:
diff --git a/tests/contrib/test_purl2url.py b/tests/contrib/test_purl2url.py
index 64ea924..fee98d4 100644
--- a/tests/contrib/test_purl2url.py
+++ b/tests/contrib/test_purl2url.py
@@ -68,6 +68,8 @@ def test_purl2url_get_repo_url():
         "pkg:golang/gopkg.in/ldap.v3@v3.1.0": "https://pkg.go.dev/gopkg.in/ldap.v3@v3.1.0",
         "pkg:cocoapods/AFNetworking@4.0.1": "https://cocoapods.org/pods/AFNetworking",
         "pkg:cocoapods/MapsIndoors@3.24.0": "https://cocoapods.org/pods/MapsIndoors",
+        "pkg:maven/org.apache.commons/commons-io@1.3.2": "https://repo.maven.apache.org/maven2/org/apache/commons/commons-io/1.3.2",
+        "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",
     }
 
     for purl, url in purls_url.items():
@@ -92,6 +94,10 @@ def test_purl2url_get_download_url():
         "pkg:gitlab/tg1999/firebase@1a122122": "https://gitlab.com/tg1999/firebase/-/archive/1a122122/firebase-1a122122.tar.gz",
         "pkg:gitlab/tg1999/firebase@1a122122?version_prefix=v": "https://gitlab.com/tg1999/firebase/-/archive/v1a122122/firebase-v1a122122.tar.gz",
         "pkg:gitlab/hoppr/hoppr@v1.11.1-dev.2": "https://gitlab.com/hoppr/hoppr/-/archive/v1.11.1-dev.2/hoppr-v1.11.1-dev.2.tar.gz",
+        "pkg:maven/org.apache.commons/commons-io@1.3.2": "https://repo.maven.apache.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?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",
         # 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",
@@ -150,6 +156,10 @@ def test_purl2url_get_inferred_urls():
         "pkg:cocoapods/AFNetworking@4.0.1": ["https://cocoapods.org/pods/AFNetworking"],
         "pkg:composer/psr/log@1.1.3": ["https://packagist.org/packages/psr/log#1.1.3"],
         "pkg:rubygems/package-name": ["https://rubygems.org/gems/package-name"],
+        "pkg:maven/org.apache.commons/commons-io@1.3.2": [
+            "https://repo.maven.apache.org/maven2/org/apache/commons/commons-io/1.3.2",
+            "https://repo.maven.apache.org/maven2/org/apache/commons/commons-io/1.3.2/commons-io-1.3.2.jar",
+        ],
         "pkg:bitbucket/birkenfeld": [],
     }
 
diff --git a/tests/test_packageurl.py b/tests/test_packageurl.py
index cb419e2..5d14220 100644
--- a/tests/test_packageurl.py
+++ b/tests/test_packageurl.py
@@ -330,3 +330,47 @@ class NormalizePurlTest(unittest.TestCase):
 def test_purl_is_hashable():
     s = {PackageURL(name="hashable", type="pypi")}
     assert len(s) == 1
+
+
+def test_colons_in_name_are_handled_correctly() -> None:
+    p = PackageURL.from_string(
+        "pkg:nuget/libiconv:%20character%20set%20conversion%20library@1.9?package-id=e11a609df352e292"
+    )
+
+    assert p.type == "nuget"
+    assert p.namespace is None
+    assert p.name == "libiconv: character set conversion library"
+    assert p.version == "1.9"
+    assert p.qualifiers == {"package-id": "e11a609df352e292"}
+    assert p.subpath is None
+
+    assert PackageURL.from_string(p.to_string()).to_string() == p.to_string()
+
+
+def test_colons_in_namespace_are_handled_correctly() -> None:
+    p = PackageURL.from_string(
+        "pkg:nuget/an:odd:space/libiconv:%20character%20set%20conversion%20library@1.9?package-id=e11a609df352e292"
+    )
+
+    assert p.type == "nuget"
+    assert p.namespace == "an:odd:space"
+    assert p.name == "libiconv: character set conversion library"
+    assert p.version == "1.9"
+    assert p.qualifiers == {"package-id": "e11a609df352e292"}
+    assert p.subpath is None
+
+    assert PackageURL.from_string(p.to_string()).to_string() == p.to_string()
+
+
+def test_encoding_stuff_with_colons_correctly() -> None:
+    p = PackageURL(
+        type="nuget",
+        namespace="an:odd:space",
+        name="libiconv: character set conversion library",
+        version="1.9",
+        qualifiers={"package-id": "e11a609df352e292"},
+    )
+    assert (
+        p.to_string()
+        == "pkg:nuget/an:odd:space/libiconv:%20character%20set%20conversion%20library@1.9?package-id=e11a609df352e292"
+    )