Repository: hamnis/maven-artifact

Issue:
[BUG] Downloading artifact with latest or None version doesn't work
When giving a gav like 'group:artifact:latest' Then according to the resolver class it should search for the newest version in the meta xml. But the artifact.uri call inside the uri_for_artifact method resolves the uri incorrectly instead using the newly found version it uses 'latest' or 'None' respectively

Discussion:

Solution patch:
diff --git a/.gitignore b/.gitignore
index 0031a7b..2dbf33c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,3 +46,6 @@ _version.py
 
 # venv
 pyvenv.cfg
+
+# other
+.idea
diff --git a/pyproject.toml b/pyproject.toml
index f366888..1297dc1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,7 +43,8 @@ classifiers = [
     'Programming Language :: Python :: 3.9',
     'Programming Language :: Python :: 3.10',
     'Programming Language :: Python :: 3.11',
- 
+    'Programming Language :: Python :: 3.12',
+
     "Operating System :: OS Independent",
 ]
 
diff --git a/src/maven_artifact/__init__.py b/src/maven_artifact/__init__.py
index 3fba994..e320819 100644
--- a/src/maven_artifact/__init__.py
+++ b/src/maven_artifact/__init__.py
@@ -1,5 +1,5 @@
-from maven_artifact.requestor import Requestor, RequestException  # noqa: F401
 from maven_artifact.artifact import Artifact  # noqa: F401
-from maven_artifact.resolver import Resolver  # noqa: F401
 from maven_artifact.downloader import Downloader  # noqa: F401
+from maven_artifact.requestor import RequestException, Requestor  # noqa: F401
+from maven_artifact.resolver import Resolver  # noqa: F401
 from maven_artifact.utils import Utils  # noqa: F401
diff --git a/src/maven_artifact/artifact.py b/src/maven_artifact/artifact.py
index ec938ab..10ccd50 100644
--- a/src/maven_artifact/artifact.py
+++ b/src/maven_artifact/artifact.py
@@ -1,8 +1,17 @@
+import copy
 import os
+from typing import Optional
 
 
-class Artifact(object):
-    def __init__(self, group_id, artifact_id, version, classifier=None, extension=None):
+class Artifact:
+    def __init__(
+        self,
+        group_id: str,
+        artifact_id: str,
+        version: Optional[str],
+        classifier: Optional[str] = None,
+        extension: Optional[str] = None,
+    ):
         if not group_id:
             raise ValueError("group_id must be set")
         if not artifact_id:
@@ -13,33 +22,50 @@ def __init__(self, group_id, artifact_id, version, classifier=None, extension=No
         self.version = version
         self.classifier = classifier
         self.extension = extension or "jar"
+        self.resolved_version = None
 
     def is_snapshot(self):
         return self.version and self.version.endswith("-SNAPSHOT")
 
-    def path(self, with_version=True):
+    def path(self, with_version: bool = True):
         base = self.group_id.replace(".", "/") + "/" + self.artifact_id
-        return base if not with_version else f"{base}/{self.version}"
+        actual_version = self.version if self.version != "latest" else self.resolved_version
+        return base if not with_version else f"{base}/{actual_version}"
 
-    def uri(self, base, resolved_version=None):
-        if self.is_snapshot() and not resolved_version:
+    def uri(self, base: str):
+        if self.is_snapshot() and not self.resolved_version:
             raise ValueError("Expected unique version for snapshot artifact " + str(self))
-        elif not self.is_snapshot():
-            resolved_version = self.version
-        ret = f"{base}/{self.path()}/{self.artifact_id}-{resolved_version}"
+        if self.resolved_version:
+            actual_version = self.resolved_version
+        else:
+            actual_version = self.version
+
+        ret = f"{base}/{self.path()}/{self.artifact_id}-{actual_version}"
         if self.classifier:
             ret += "-" + self.classifier
         return f"{ret}.{self.extension}"
 
-    def with_version(self, _version):
-        return Artifact(self.group_id, self.artifact_id, _version, self.classifier, self.extension)
+    def with_version(self, version: str):
+        artifact = copy.copy(self)
+        artifact.version = version
+        return artifact
+
+    def with_classifier(self, classifier: str):
+        artifact = copy.copy(self)
+        artifact.classifier = classifier
+        return artifact
+
+    def with_resolved_version(self, version: str):
+        artifact = copy.copy(self)
+        artifact.resolved_version = version
+        return artifact
 
     def _generate_filename(self):
         if not self.classifier:
             return f"{self.artifact_id}.{self.extension}"
         return f"{self.artifact_id}-{self.classifier}.{self.extension}"
 
-    def get_filename(self, filename=None):
+    def get_filename(self, filename: Optional[str] = None):
         if not filename:
             filename = self._generate_filename()
         elif os.path.isdir(filename):
@@ -48,11 +74,11 @@ def get_filename(self, filename=None):
 
     def __str__(self):
         if self.classifier:
-            return "%s:%s:%s:%s:%s" % (self.group_id, self.artifact_id, self.extension, self.classifier, self.version)
+            return f"{self.group_id}:{self.artifact_id}:{self.extension}:{self.classifier}:{self.version}"
         elif self.extension != "jar":
-            return "%s:%s:%s:%s" % (self.group_id, self.artifact_id, self.extension, self.version)
+            return f"{self.group_id}:{self.artifact_id}:{self.extension}:{self.version}"
         else:
-            return "%s:%s:%s" % (self.group_id, self.artifact_id, self.version)
+            return f"{self.group_id}:{self.artifact_id}:{self.version}"
 
     def __eq__(self, other):
         return (
@@ -65,7 +91,7 @@ def __eq__(self, other):
         )
 
     @staticmethod
-    def parse(maven_coordinate):
+    def parse(maven_coordinate: str):
         parts = maven_coordinate.split(":")
         if len(parts) >= 3:
             g = parts[0]
diff --git a/src/maven_artifact/downloader.py b/src/maven_artifact/downloader.py
index bdd70aa..defb1ae 100644
--- a/src/maven_artifact/downloader.py
+++ b/src/maven_artifact/downloader.py
@@ -5,7 +5,7 @@
 from maven_artifact.resolver import Resolver
 
 
-class Downloader(object):
+class Downloader:
     def __init__(self, base="https://repo.maven.apache.org/maven2", username=None, password=None, token=None):
         self.requestor = Requestor(username=username, password=password, token=token)
         self.resolver = Resolver(base, self.requestor)
diff --git a/src/maven_artifact/main.py b/src/maven_artifact/main.py
index 816c907..66cb138 100755
--- a/src/maven_artifact/main.py
+++ b/src/maven_artifact/main.py
@@ -14,9 +14,9 @@
 import textwrap
 
 from maven_artifact.artifact import Artifact
-from maven_artifact.utils import Utils
-from maven_artifact.requestor import RequestException
 from maven_artifact.downloader import Downloader
+from maven_artifact.requestor import RequestException
+from maven_artifact.utils import Utils
 
 
 class DescriptionWrappedNewlineFormatter(argparse.HelpFormatter):
@@ -59,7 +59,6 @@ def _split_lines(self, text, width):
 
 
 class MainCommand:
-
     def _get_arguments(self):
         parser = argparse.ArgumentParser(formatter_class=WrappedNewlineFormatter, epilog=__epilog__)
 
diff --git a/src/maven_artifact/requestor.py b/src/maven_artifact/requestor.py
index 4f36bbf..07df903 100644
--- a/src/maven_artifact/requestor.py
+++ b/src/maven_artifact/requestor.py
@@ -1,4 +1,5 @@
 import base64
+
 import requests
 
 from maven_artifact.utils import Utils
@@ -9,7 +10,7 @@ def __init__(self, msg):
         self.msg = msg
 
 
-class Requestor(object):
+class Requestor:
     def __init__(
         self,
         username=None,
diff --git a/src/maven_artifact/resolver.py b/src/maven_artifact/resolver.py
index 6519282..bab0152 100644
--- a/src/maven_artifact/resolver.py
+++ b/src/maven_artifact/resolver.py
@@ -2,27 +2,32 @@
 
 from maven_artifact.requestor import RequestException
 
+from maven_artifact.requestor import Requestor
 
-class Resolver(object):
-    def __init__(self, base, requestor):
+
+class Resolver:
+    def __init__(self, base: str, requestor: Requestor):
         self.requestor = requestor
         if base.endswith("/"):
             base = base.rstrip("/")
         self.base = base
 
+    @staticmethod
+    def default():
+        return Resolver("https://repo1.maven.org/maven2", Requestor())
+
     def _find_latest_version_available(self, artifact):
-        path = "/%s/maven-metadata.xml" % (artifact.path(False))
+        path = f"/{artifact.path(False)}/maven-metadata.xml"
         xml = self.requestor.request(self.base + path, self._onFail, lambda r: etree.fromstring(r.content))
         v = xml.xpath("/metadata/versioning/versions/version[last()]/text()")
-        if v:
-            return v[0]
+        return v[0] if v else None
 
     def _find_latest_snapshot_version(self, artifact):
-        path = "/%s/maven-metadata.xml" % (artifact.path())
+        path = f"/{artifact.path()}/maven-metadata.xml"
         xml = self.requestor.request(self.base + path, self._onFail, lambda r: etree.fromstring(r.content))
         timestamp = xml.xpath("/metadata/versioning/snapshot/timestamp/text()")[0]
-        buildNumber = xml.xpath("/metadata/versioning/snapshot/buildNumber/text()")[0]
-        meta_version = f"{timestamp}-{buildNumber}"
+        build_number = xml.xpath("/metadata/versioning/snapshot/buildNumber/text()")[0]
+        meta_version = f"{timestamp}-{build_number}"
         # obtain exact version value
         versions = xml.xpath("/metadata/versioning/snapshotVersions/snapshotVersion/value/text()")
         for version in set(versions):
@@ -39,8 +44,8 @@ def resolve(self, artifact):
             version = self._find_latest_version_available(artifact)
         elif artifact.is_snapshot():
             version = self._find_latest_snapshot_version(artifact)
-        return artifact.with_version(version)
+        return artifact.with_resolved_version(version)
 
     def uri_for_artifact(self, artifact):
         resolved = self.resolve(artifact)
-        return artifact.uri(self.base, resolved.version)
+        return resolved.uri(self.base)


Test patch:
diff --git a/tests/integration/maven_artifact/test_resolver.py b/tests/integration/maven_artifact/test_resolver.py
new file mode 100644
index 0000000..b08f17b
--- /dev/null
+++ b/tests/integration/maven_artifact/test_resolver.py
@@ -0,0 +1,15 @@
+from maven_artifact import Resolver
+from maven_artifact.artifact import Artifact
+
+
+def test_resolve_artifact():
+    resolver = Resolver.default()
+    parsed_artifact = Artifact.parse("javax.servlet:servlet-api:latest")
+    assert parsed_artifact.resolved_version is None
+    resolved = resolver.resolve(parsed_artifact)
+    assert resolved.resolved_version == "3.0-alpha-1"
+    assert resolved.version == "latest"
+    uri = resolver.uri_for_artifact(parsed_artifact)
+    other_uri = resolved.uri(resolver.base)
+    assert uri == "https://repo1.maven.org/maven2/javax/servlet/servlet-api/3.0-alpha-1/servlet-api-3.0-alpha-1.jar"
+    assert other_uri == uri
diff --git a/tests/unit/maven_artifact/test_artifact.py b/tests/unit/maven_artifact/test_artifact.py
index 9ff3c88..8305728 100644
--- a/tests/unit/maven_artifact/test_artifact.py
+++ b/tests/unit/maven_artifact/test_artifact.py
@@ -1,4 +1,5 @@
 import pytest
+
 from maven_artifact import Artifact
 
 
@@ -14,7 +15,7 @@ def test_issnapshot():
 
     assert artifact.is_snapshot() is False
 
-    artifact = artifact.with_version(_version="0.0.1-SNAPSHOT")
+    artifact = artifact.with_version(version="0.0.1-SNAPSHOT")
 
     assert artifact.is_snapshot() is True