From 982612437dccd9537711c7ed840dbfafc0ec8961 Mon Sep 17 00:00:00 2001 From: David Mathias Mortensen Date: Mon, 30 Dec 2024 16:42:55 +0100 Subject: [PATCH 1/6] feat: Add latest, latest-stable and latest-unstable support for mavenlocation --- pyproject.toml | 9 +- src/reqstool/locations/maven_location.py | 115 ++++++++++++------ .../reqstool/locations/test_git_location.py | 2 +- .../reqstool/locations/test_maven_location.py | 61 ++++++++++ 4 files changed, 148 insertions(+), 39 deletions(-) create mode 100644 tests/unit/reqstool/locations/test_maven_location.py diff --git a/pyproject.toml b/pyproject.toml index 33bd23ee..45387418 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,9 +69,12 @@ source = "vcs" [tool.hatch.version.raw-options] local_scheme = "no-local-version" -[tool.hatch.build.targets.wheel.hooks.decorators] -dependencies = ["reqstool-python-hatch-plugin==0.0.4"] -path = ["src", "tests"] +[tool.hatch.build.hooks.reqstool] +dependencies = ["reqstool-python-hatch-plugin == 0.1.1"] +sources = ["src", "tests"] +dataset_directory = "docs/reqstool" +output_directory = "build/reqstool" +test_results = ["build/**/junit.xml"] [tool.hatch.envs.dev] dependencies = [ diff --git a/src/reqstool/locations/maven_location.py b/src/reqstool/locations/maven_location.py index bb6ec3f9..32ab3103 100644 --- a/src/reqstool/locations/maven_location.py +++ b/src/reqstool/locations/maven_location.py @@ -4,14 +4,14 @@ import os import sys from dataclasses import dataclass, field -from typing import Optional +from typing import Any, Optional from zipfile import ZipFile - -from maven_artifact import Artifact, Downloader, RequestException +from maven_artifact import Artifact, Downloader, RequestException, Resolver from reqstool_python_decorators.decorators.decorators import Requirements - from reqstool.locations.location import LocationInterface +from lxml import etree + @Requirements("REQ_003", "REQ_017") @dataclass(kw_only=True) @@ -19,53 +19,98 @@ class MavenLocation(LocationInterface): url: Optional[str] = "https://repo.maven.apache.org/maven2" group_id: str artifact_id: str - version: str + version: str # Can be "latest", "latest-stable", "latest-unstable", or specific version classifier: str = field(default="reqstool") env_token: str - def _make_available_on_localdisk(self, dst_path: str): - token = os.getenv(self.env_token) + def _get_versions(self, resolver: Resolver, artifact: Artifact) -> Any: + """Get all available versions for the artifact.""" + try: + # Construct Maven metadata path + group_path = artifact.group_id.replace("/", ".") # Convert slashes to dots first + group_path = group_path.replace(".", "/") # Then convert dots to slashes + path = f"/{group_path}/{artifact.artifact_id}/maven-metadata.xml" - # assume OAuth Bearer, see: https://georgearisty.dev/posts/oauth2-token-bearer-usage/ - downloader = Downloader(base=self.url, token=token) + xml = resolver.requestor.request( + resolver.base + path, resolver._onFail, lambda r: etree.fromstring(r.content) + ) + all_versions = xml.xpath("/metadata/versioning/versions/version/text()") - artifact = Artifact( - group_id=self.group_id, - artifact_id=self.artifact_id, - version=self.version, - classifier=self.classifier, - extension="zip", - ) + if not all_versions: + raise RequestException(f"No versions found for {artifact}") - logging.debug(f"Downloading {artifact} from {self.url} to {dst_path}") + return all_versions + except Exception as e: + raise RequestException(f"Failed to get versions for {artifact}: {str(e)}") - try: - if not downloader.download(artifact, filename=dst_path): - raise RequestException(f"Error downloading artifact {artifact} from: {self.url}") - except RequestException as e: - logging.fatal(e.msg) - sys.exit(1) + def _resolve_version(self, resolver: Resolver, base_artifact: Artifact) -> str: + """Resolve version based on version specifier.""" + if self.version == "latest": + versions = self._get_versions(resolver, base_artifact) + if not versions: + raise RequestException(f"No versions found for {self.group_id}:{self.artifact_id}") + return versions[-1] - logging.debug(f"Unzipping {artifact.get_filename(dst_path)} to {dst_path}\n") + if self.version not in ["latest-stable", "latest-unstable"]: + return self.version - with ZipFile(artifact.get_filename(dst_path), "r") as zip_ref: - # Extracting all the members of the zip - # into a specific location. - top_level_dirs = {name.split("/")[0] for name in zip_ref.namelist() if "/" in name} + versions = self._get_versions(resolver, base_artifact) + is_stable = self.version == "latest-stable" + filtered_versions = [v for v in versions if v.endswith("-SNAPSHOT") != is_stable] + if not filtered_versions: + version_type = "stable" if is_stable else "unstable" + raise RequestException(f"No {version_type} versions found for {self.group_id}:{self.artifact_id}") + + return filtered_versions[-1] + + def _extract_zip(self, zip_file: str, dst_path: str) -> str: + """Extract ZIP file and return top level directory.""" + logging.debug(f"Unzipping {zip_file} to {dst_path}") + with ZipFile(zip_file, "r") as zip_ref: + top_level_dirs = {name.split("/")[0] for name in zip_ref.namelist() if "/" in name} zip_ref.extractall(path=dst_path) if len(top_level_dirs) != 1: - logging.fatal( - f"Maven zip artifact {artifact} from {self.url} did not have one and only one" - f" top level directory: {top_level_dirs}" + raise RequestException( + f"Maven zip artifact does not have one and only one top level directory: {top_level_dirs}" ) - sys.exit(1) top_level_dir = os.path.join(dst_path, top_level_dirs.pop()) + logging.debug(f"Unzipped {zip_file} to {top_level_dir}") + return top_level_dir + + def _make_available_on_localdisk(self, dst_path: str) -> str: + token = os.getenv(self.env_token) + downloader = Downloader(base=self.url, token=token) + resolver = downloader.resolver + + try: + base_artifact = Artifact( + group_id=self.group_id, version=self.version, artifact_id=self.artifact_id, classifier=self.classifier + ) + version = self._resolve_version(resolver, base_artifact) + + artifact = Artifact( + group_id=self.group_id, + artifact_id=self.artifact_id, + version=version, + classifier=self.classifier, + extension="zip", + ) + resolved_artifact = resolver.resolve(artifact) - # os.remove(artifact.get_filename(dst_path)) + if resolved_artifact.version != version: + logging.debug(f"Resolved version '{version}' to: {resolved_artifact.version}") - logging.debug(f"Unzipped {artifact.get_filename(dst_path)} to {top_level_dir}\n") + if not downloader.download(resolved_artifact, filename=dst_path): + raise RequestException(f"Error downloading artifact {resolved_artifact} from: {self.url}") - return top_level_dir + return self._extract_zip(resolved_artifact.get_filename(dst_path), dst_path) + + except RequestException as e: + logging.fatal(str(e)) + sys.exit(1) + except Exception as e: + logging.fatal(f"Unexpected error: {str(e)}") + sys.exit(1) diff --git a/tests/unit/reqstool/locations/test_git_location.py b/tests/unit/reqstool/locations/test_git_location.py index ed02c2c2..9d75910b 100644 --- a/tests/unit/reqstool/locations/test_git_location.py +++ b/tests/unit/reqstool/locations/test_git_location.py @@ -3,7 +3,7 @@ from reqstool.locations.git_location import GitLocation -def test_git_location(resource_funcname_rootdir_w_path): +def test_git_location(resource_funcname_rootdir_w_path: str) -> None: PATH = "/tmp/somepath" git_location = GitLocation( diff --git a/tests/unit/reqstool/locations/test_maven_location.py b/tests/unit/reqstool/locations/test_maven_location.py new file mode 100644 index 00000000..943ba51e --- /dev/null +++ b/tests/unit/reqstool/locations/test_maven_location.py @@ -0,0 +1,61 @@ +from reqstool.locations.maven_location import MavenLocation +from maven_artifact import Artifact, Downloader, Resolver + + +def test_maven_location() -> None: + maven_location = MavenLocation( + group_id="se/lfv/reqstool", + artifact_id="reqstool-maven-plugin", + version="0.1.0", + env_token="TOKEN", + ) + token = "TOKEN" + downloader = Downloader(base="https://repo.maven.apache.org/maven2", token=token) + resolver = Resolver(base="https://repo.maven.apache.org/maven2", requestor=downloader.requestor) + + # Create proper Artifact instance + artifact = Artifact( + group_id=maven_location.group_id, + artifact_id=maven_location.artifact_id, + version=maven_location.version, + classifier=maven_location.classifier, + ) + + version = maven_location._resolve_version(resolver=resolver, base_artifact=artifact) + + assert version == "0.1.0" + + maven_location.version = "latest" + + artifact_version_latest = Artifact( + group_id=maven_location.group_id, + artifact_id=maven_location.artifact_id, + version=maven_location.version, + classifier=maven_location.classifier, + ) + + latest_version = maven_location._resolve_version(resolver=resolver, base_artifact=artifact_version_latest) + + # Need to get the latest version instead of hardcoding "1.0.0" here as this test will break as soon as there is a new + # version of the maven plugin + assert latest_version == "1.0.0" + + +# Test to simply run the function, seems to be working but there is no reqstool.zip +# in the artifact so it fails when trying to access it. + +# def test_make_available_on_localdisc() -> None: +# maven_location = MavenLocation( +# group_id="se/lfv/reqstool", +# artifact_id="reqstool-maven-plugin", +# version="latest-stable", +# env_token="TOKEN", +# ) + +# versions = maven_location._make_available_on_localdisk(dst_path=".") +# # Filter stable versions and get latest +# stable_versions = [v for v in versions if not v.endswith("-SNAPSHOT")] +# if stable_versions: +# maven_location.version = stable_versions[-1] # Update the version + +# assert maven_location.version == "1.0.0" From 2b845884f18e5f8716f01c5572d5def74c8b5060 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Fri, 10 Jan 2025 11:44:23 +0100 Subject: [PATCH 2/6] fix: Minor fixes to maven_location --- src/reqstool/locations/maven_location.py | 33 ++++++++++++++---------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/reqstool/locations/maven_location.py b/src/reqstool/locations/maven_location.py index 32ab3103..24c66927 100644 --- a/src/reqstool/locations/maven_location.py +++ b/src/reqstool/locations/maven_location.py @@ -4,13 +4,14 @@ import os import sys from dataclasses import dataclass, field -from typing import Any, Optional +from typing import List, Optional from zipfile import ZipFile + +from lxml import etree from maven_artifact import Artifact, Downloader, RequestException, Resolver from reqstool_python_decorators.decorators.decorators import Requirements -from reqstool.locations.location import LocationInterface -from lxml import etree +from reqstool.locations.location import LocationInterface @Requirements("REQ_003", "REQ_017") @@ -23,7 +24,7 @@ class MavenLocation(LocationInterface): classifier: str = field(default="reqstool") env_token: str - def _get_versions(self, resolver: Resolver, artifact: Artifact) -> Any: + def _get_all_versions(self, resolver: Resolver, artifact: Artifact) -> List[str]: """Get all available versions for the artifact.""" try: # Construct Maven metadata path @@ -34,7 +35,7 @@ def _get_versions(self, resolver: Resolver, artifact: Artifact) -> Any: xml = resolver.requestor.request( resolver.base + path, resolver._onFail, lambda r: etree.fromstring(r.content) ) - all_versions = xml.xpath("/metadata/versioning/versions/version/text()") + all_versions: List[str] = xml.xpath("/metadata/versioning/versions/version/text()") if not all_versions: raise RequestException(f"No versions found for {artifact}") @@ -45,19 +46,23 @@ def _get_versions(self, resolver: Resolver, artifact: Artifact) -> Any: def _resolve_version(self, resolver: Resolver, base_artifact: Artifact) -> str: """Resolve version based on version specifier.""" + + if self.version not in ["latest", "latest-stable", "latest-unstable"]: + return self.version + if self.version == "latest": - versions = self._get_versions(resolver, base_artifact) + versions = self._get_all_versions(resolver, base_artifact) + if not versions: raise RequestException(f"No versions found for {self.group_id}:{self.artifact_id}") - return versions[-1] - if self.version not in ["latest-stable", "latest-unstable"]: - return self.version + return versions[-1] - versions = self._get_versions(resolver, base_artifact) + versions = self._get_all_versions(resolver, base_artifact) is_stable = self.version == "latest-stable" filtered_versions = [v for v in versions if v.endswith("-SNAPSHOT") != is_stable] + # no matching version found if not filtered_versions: version_type = "stable" if is_stable else "unstable" raise RequestException(f"No {version_type} versions found for {self.group_id}:{self.artifact_id}") @@ -89,19 +94,19 @@ def _make_available_on_localdisk(self, dst_path: str) -> str: base_artifact = Artifact( group_id=self.group_id, version=self.version, artifact_id=self.artifact_id, classifier=self.classifier ) - version = self._resolve_version(resolver, base_artifact) + resolved_version = self._resolve_version(resolver, base_artifact) artifact = Artifact( group_id=self.group_id, artifact_id=self.artifact_id, - version=version, + version=resolved_version, classifier=self.classifier, extension="zip", ) resolved_artifact = resolver.resolve(artifact) - if resolved_artifact.version != version: - logging.debug(f"Resolved version '{version}' to: {resolved_artifact.version}") + if resolved_artifact.version != resolved_version: + logging.debug(f"Resolved version '{self.version}' to: {resolved_artifact.version}") if not downloader.download(resolved_artifact, filename=dst_path): raise RequestException(f"Error downloading artifact {resolved_artifact} from: {self.url}") From 934a8178a446813420810a36b13ece337b8e81d9 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Fri, 10 Jan 2025 16:13:20 +0100 Subject: [PATCH 3/6] feat: Implement latest versions for pypi --- src/reqstool/locations/maven_location.py | 44 ++--- src/reqstool/locations/pypi_location.py | 153 ++++++++++++++++-- .../reqstool/locations/test_maven_location.py | 24 +-- .../reqstool/locations/test_pypi_location.py | 23 +++ 4 files changed, 195 insertions(+), 49 deletions(-) create mode 100644 tests/unit/reqstool/locations/test_pypi_location.py diff --git a/src/reqstool/locations/maven_location.py b/src/reqstool/locations/maven_location.py index 24c66927..e14073d7 100644 --- a/src/reqstool/locations/maven_location.py +++ b/src/reqstool/locations/maven_location.py @@ -22,14 +22,22 @@ class MavenLocation(LocationInterface): artifact_id: str version: str # Can be "latest", "latest-stable", "latest-unstable", or specific version classifier: str = field(default="reqstool") - env_token: str + env_token: Optional[str] + token: Optional[str] = field(init=False, default=None) + + def __post_init__(self) -> None: + # Retrieve token from environment variable + self.token = os.getenv(self.env_token) if self.env_token else None + + if self.token: + logging.debug("Using OAuth Bearer token for authentication") def _get_all_versions(self, resolver: Resolver, artifact: Artifact) -> List[str]: """Get all available versions for the artifact.""" try: # Construct Maven metadata path - group_path = artifact.group_id.replace("/", ".") # Convert slashes to dots first - group_path = group_path.replace(".", "/") # Then convert dots to slashes + group_path: str = artifact.group_id.replace("/", ".") # Convert slashes to dots first + group_path: str = group_path.replace(".", "/") # Then convert dots to slashes path = f"/{group_path}/{artifact.artifact_id}/maven-metadata.xml" xml = resolver.requestor.request( @@ -44,27 +52,27 @@ def _get_all_versions(self, resolver: Resolver, artifact: Artifact) -> List[str] except Exception as e: raise RequestException(f"Failed to get versions for {artifact}: {str(e)}") - def _resolve_version(self, resolver: Resolver, base_artifact: Artifact) -> str: + def _resolve_version(self, resolver: Resolver) -> str: """Resolve version based on version specifier.""" if self.version not in ["latest", "latest-stable", "latest-unstable"]: return self.version - if self.version == "latest": - versions = self._get_all_versions(resolver, base_artifact) + base_artifact: Artifact = Artifact( + group_id=self.group_id, version=self.version, artifact_id=self.artifact_id, classifier=self.classifier + ) - if not versions: - raise RequestException(f"No versions found for {self.group_id}:{self.artifact_id}") + all_versions: List[str] = self._get_all_versions(resolver, base_artifact) - return versions[-1] + if self.version == "latest": + return all_versions[-1] - versions = self._get_all_versions(resolver, base_artifact) is_stable = self.version == "latest-stable" - filtered_versions = [v for v in versions if v.endswith("-SNAPSHOT") != is_stable] + filtered_versions = [v for v in all_versions if v.endswith("-SNAPSHOT") != is_stable] # no matching version found if not filtered_versions: - version_type = "stable" if is_stable else "unstable" + version_type: str = "stable" if is_stable else "unstable" raise RequestException(f"No {version_type} versions found for {self.group_id}:{self.artifact_id}") return filtered_versions[-1] @@ -86,24 +94,20 @@ def _extract_zip(self, zip_file: str, dst_path: str) -> str: return top_level_dir def _make_available_on_localdisk(self, dst_path: str) -> str: - token = os.getenv(self.env_token) - downloader = Downloader(base=self.url, token=token) + downloader = Downloader(base=self.url, token=self.token) resolver = downloader.resolver try: - base_artifact = Artifact( - group_id=self.group_id, version=self.version, artifact_id=self.artifact_id, classifier=self.classifier - ) - resolved_version = self._resolve_version(resolver, base_artifact) + resolved_version: str = self._resolve_version(resolver) - artifact = Artifact( + artifact: Artifact = Artifact( group_id=self.group_id, artifact_id=self.artifact_id, version=resolved_version, classifier=self.classifier, extension="zip", ) - resolved_artifact = resolver.resolve(artifact) + resolved_artifact: Artifact = resolver.resolve(artifact) if resolved_artifact.version != resolved_version: logging.debug(f"Resolved version '{self.version}' to: {resolved_artifact.version}") diff --git a/src/reqstool/locations/pypi_location.py b/src/reqstool/locations/pypi_location.py index 2fdce1b1..36985d43 100644 --- a/src/reqstool/locations/pypi_location.py +++ b/src/reqstool/locations/pypi_location.py @@ -4,14 +4,51 @@ import sys import tarfile from dataclasses import dataclass, field -from typing import Optional +from typing import List, Optional from urllib.parse import urljoin import requests from bs4 import BeautifulSoup + from reqstool.common.utils import Utils from reqstool.locations.location import LocationInterface +# https://packaging.python.org/en/latest/specifications/version-specifiers/#version-specifiers-regex +VERSION_PATTERN = r""" + v? + (?P # Combined version (epoch + release) + (?:(?P[0-9]+)!)? # epoch + (?P[0-9]+(?:\.[0-9]+)*) # release segment + ) + (?P # extra: everything after version + (?P
                                          # pre-release
+            [-_\.]?
+            (?P(a|b|c|rc|alpha|beta|pre|preview))
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+        (?P                                         # post release
+            (?:-(?P[0-9]+))
+            |
+            (?:
+                [-_\.]?
+                (?Ppost|rev|r)
+                [-_\.]?
+                (?P[0-9]+)?
+            )
+        )?
+        (?P                                          # dev release
+            [-_\.]?
+            (?Pdev)
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+        (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?   # local version
+    )?
+"""
+RE_PEP440_VERSION: re.Pattern[str] = re.compile(VERSION_PATTERN, re.VERBOSE | re.IGNORECASE)
+RE_PYPI_SIMPLE_API_HREF_VERSION = re.compile(VERSION_PATTERN + r"\.tar\.gz", re.VERBOSE | re.IGNORECASE)
+
 
 @dataclass(kw_only=True)
 class PypiLocation(LocationInterface):
@@ -19,22 +56,26 @@ class PypiLocation(LocationInterface):
     package: str
     version: str
     env_token: Optional[str] = field(default=None)
+    token: Optional[str] = field(init=False, default=None)
+
+    def __post_init__(self) -> None:
+        # Retrieve token from environment variable
+        self.token = os.getenv(self.env_token) if self.env_token else None
+
+        if self.token:
+            logging.debug("Using OAuth Bearer token for authentication")
 
     @staticmethod
-    def normalize_pypi_package_name(package_name):
+    def normalize_pypi_package_name(package_name: str) -> str:
         return re.sub(r"[-_.]+", "-", package_name).lower()
 
-    def _make_available_on_localdisk(self, dst_path: str):
+    def _make_available_on_localdisk(self, dst_path: str) -> str:
         """
         Download the PyPI package and extract it to the local disk.
         """
-        # Retrieve token from environment variable
-        token = os.getenv(self.env_token)
+        resolved_version: str = self._resolve_version()
 
-        if token:
-            logging.debug("Using OAuth Bearer token for authentication")
-
-        package_url = self.get_package_url(self.package, self.version, self.url, token)
+        package_url = PypiLocation.get_package_url(self.package, resolved_version, self.url, self.token)
 
         if not package_url:
             token_info = f"(with token in environment variable '{self.env_token}')" if self.env_token else ""
@@ -45,7 +86,7 @@ def _make_available_on_localdisk(self, dst_path: str):
         logging.debug(f"Downloading {self.package} from {self.url} to {dst_path}\n")
 
         try:
-            downloaded_file = Utils.download_file(url=package_url, dst_path=dst_path, token=token)
+            downloaded_file = Utils.download_file(url=package_url, dst_path=dst_path, token=self.token)
 
             logging.debug(f"Extracting {downloaded_file} to {dst_path}\n")
 
@@ -60,7 +101,7 @@ def _make_available_on_localdisk(self, dst_path: str):
         except Exception as e:
             logging.fatal(
                 f"Error when downloading etc sdist pypi package for {self.package}=={self.version}"
-                f" in repo {self.url} {'with token' if token else ''}",
+                f" in repo {self.url} {'with token' if self.token else ''}",
                 e,
             )
             sys.exit(1)
@@ -77,8 +118,96 @@ def _make_available_on_localdisk(self, dst_path: str):
 
         return top_level_dir
 
+    def _resolve_version(self) -> str:
+        """Resolve version based on version specifier."""
+
+        if self.version not in ["latest", "latest-stable", "latest-unstable"]:
+            return self.version
+
+        all_versions: List[str] = self._get_all_versions(package=self.package, base_url=self.url, token=self.token)
+
+        if self.version == "latest":
+            return all_versions[-1]
+
+        is_stable = self.version == "latest-stable"
+
+        resolved_version: Optional[str] = None
+        for v in reversed(all_versions):
+            match: Optional[re.Match[str]] = RE_PEP440_VERSION.search(v)
+
+            if not match:
+                continue
+
+            version: str = match.group("version")
+            extra: str = match.group("extra")
+
+            if is_stable and not extra:
+                resolved_version = version
+            elif not is_stable and extra:
+                resolved_version = f"{version}{extra}"
+
+            if resolved_version:
+                break
+
+        # no matching version found
+        if not resolved_version:
+            version_type = "stable" if is_stable else "unstable"
+            raise Exception(f"No {version_type} versions found for {self.package}")
+
+        return resolved_version
+
+    @staticmethod
+    def _get_all_versions(package: str, base_url: str, token: Optional[str] = None) -> List[str]:
+        """Get all available versions for the package."""
+        package = PypiLocation.normalize_pypi_package_name(package_name=package)
+
+        if not base_url.endswith("/"):
+            base_url += "/"
+
+        # Construct the request URL by appending the package_name to base_url_path
+        url = urljoin(base_url, f"{package}/")
+
+        # Prepare headers
+        headers = {"Accept": "text/html"}
+
+        if token:
+            # If the token exists, add it as a Bearer token in the Authorization header
+            headers["Authorization"] = f"Bearer {token}"
+
+        # Make the request to the package's metadata endpoint
+        response = requests.get(url, headers=headers)
+
+        # Raise an error if the request was unsuccessful
+        response.raise_for_status()
+
+        # Parse the HTML response
+        soup = BeautifulSoup(response.content, "html.parser")
+
+        all_versions: list[str] = list()
+
+        # Get all version with sdist file
+        for link in soup.find_all("a"):
+            href = link.get("href")
+
+            # Search for the pattern in the text
+            match: Optional[re.Match[str]] = RE_PYPI_SIMPLE_API_HREF_VERSION.search(href)
+
+            if not match:
+                # $$$ log here
+                continue
+
+            # Extract semantic version value if found, otherwise set to "unknown"
+            version = match.group("version")
+
+            all_versions.append(version)
+
+        if not all_versions:
+            raise Exception(f"No versions found for {package}")
+
+        return all_versions
+
     @staticmethod
-    def get_package_url(package, version, base_url, token) -> str:
+    def get_package_url(package: str, version: str, base_url: str, token: Optional[str]) -> Optional[str]:
         package = PypiLocation.normalize_pypi_package_name(package_name=package)
 
         if not base_url.endswith("/"):
diff --git a/tests/unit/reqstool/locations/test_maven_location.py b/tests/unit/reqstool/locations/test_maven_location.py
index 943ba51e..99a9e1d8 100644
--- a/tests/unit/reqstool/locations/test_maven_location.py
+++ b/tests/unit/reqstool/locations/test_maven_location.py
@@ -1,5 +1,8 @@
+# Copyright © LFV
+
+from maven_artifact import Downloader, Resolver
+
 from reqstool.locations.maven_location import MavenLocation
-from maven_artifact import Artifact, Downloader, Resolver
 
 
 def test_maven_location() -> None:
@@ -13,29 +16,16 @@ def test_maven_location() -> None:
     downloader = Downloader(base="https://repo.maven.apache.org/maven2", token=token)
     resolver = Resolver(base="https://repo.maven.apache.org/maven2", requestor=downloader.requestor)
 
-    # Create proper Artifact instance
-    artifact = Artifact(
-        group_id=maven_location.group_id,
-        artifact_id=maven_location.artifact_id,
-        version=maven_location.version,
-        classifier=maven_location.classifier,
-    )
-
-    version = maven_location._resolve_version(resolver=resolver, base_artifact=artifact)
+    version = maven_location._resolve_version(resolver=resolver)
 
     assert version == "0.1.0"
 
     maven_location.version = "latest"
 
-    artifact_version_latest = Artifact(
-        group_id=maven_location.group_id,
-        artifact_id=maven_location.artifact_id,
-        version=maven_location.version,
-        classifier=maven_location.classifier,
+    latest_version = maven_location._resolve_version(
+        resolver=resolver,
     )
 
-    latest_version = maven_location._resolve_version(resolver=resolver, base_artifact=artifact_version_latest)
-
     # Need to get the latest version instead of hardcoding "1.0.0" here as this test will break as soon as there is a new
     # version of the maven plugin
     assert latest_version == "1.0.0"
diff --git a/tests/unit/reqstool/locations/test_pypi_location.py b/tests/unit/reqstool/locations/test_pypi_location.py
new file mode 100644
index 00000000..06a655ce
--- /dev/null
+++ b/tests/unit/reqstool/locations/test_pypi_location.py
@@ -0,0 +1,23 @@
+from typing import List
+
+from reqstool.locations.pypi_location import PypiLocation
+
+
+def test_get_all_versions() -> None:
+    all_versions: List[str] = PypiLocation._get_all_versions(package="reqstool", base_url="https://pypi.org/simple")
+
+    assert len(all_versions) > 5
+
+
+def test_resolve_version() -> None:
+    pypi_location = PypiLocation(package="reqstool", version="latest")
+
+    version_latest = pypi_location._resolve_version()
+
+    assert version_latest == "0.5.9"
+
+    pypi_location.version = "latest-stable"
+
+    version_latest_stable = pypi_location._resolve_version()
+
+    assert version_latest == version_latest_stable

From 4bc99ebbe98be039c00f849383db3677a00acad6 Mon Sep 17 00:00:00 2001
From: Jimisola Laursen 
Date: Tue, 14 Jan 2025 11:11:48 +0100
Subject: [PATCH 4/6] feat: WIP

---
 .../common/dataclasses/maven_version.py       | 38 ++++++++
 src/reqstool/locations/git_location.py        | 89 +++++++++++++++++--
 src/reqstool/locations/maven_location.py      | 14 +--
 src/reqstool/locations/pypi_location.py       | 31 +++----
 .../common/dataclasses/test_maven_versions.py | 53 +++++++++++
 5 files changed, 193 insertions(+), 32 deletions(-)
 create mode 100644 src/reqstool/common/dataclasses/maven_version.py
 create mode 100644 tests/unit/reqstool/common/dataclasses/test_maven_versions.py

diff --git a/src/reqstool/common/dataclasses/maven_version.py b/src/reqstool/common/dataclasses/maven_version.py
new file mode 100644
index 00000000..6d09d7ba
--- /dev/null
+++ b/src/reqstool/common/dataclasses/maven_version.py
@@ -0,0 +1,38 @@
+# Copyright © LFV
+
+import re
+from dataclasses import dataclass, field
+from typing import Optional
+
+
+@dataclass
+class MavenVersion:
+    version: str
+    major: int = field(init=False)
+    minor: int = field(init=False)
+    patch: int = field(init=False)
+    build_number: Optional[int] = field(init=False)
+    qualifier: Optional[str] = field(init=False)
+    snapshot: bool = field(init=False, default=False)  # New field for SNAPSHOT detection
+
+    PATTERN_SEMANTIC_VERSION: re.Pattern[str] = re.compile(
+        "^(?P\d+)\.(?P\d+)\.(?P\d+)(?:-(?P\d+))?(?:[-.]?(?P(?!SNAPSHOT$)[a-zA-Z][a-zA-Z0-9]*))?(?:-(?PSNAPSHOT))?$"
+    )
+
+    def __post_init__(self) -> None:
+        match: Optional[re.Match[str]] = self.PATTERN_SEMANTIC_VERSION.match(self.version)
+
+        if not match:
+            raise ValueError(f"Not a valid semantic Maven version string: {self.version}")
+
+        # semantic version is mandatory
+        self.major = int(match.group("major"))
+        self.minor = int(match.group("minor"))
+        self.patch = int(match.group("patch"))
+
+        self.build_number = int(match.group("buildno")) if match.group("buildno") else None
+
+        self.qualifier = match.group("qualifier")
+
+        # check if "SNAPSHOT" is in the version string
+        self.snapshot = True if match.group("snapshot") else False
diff --git a/src/reqstool/locations/git_location.py b/src/reqstool/locations/git_location.py
index 415b318f..2257c099 100644
--- a/src/reqstool/locations/git_location.py
+++ b/src/reqstool/locations/git_location.py
@@ -2,13 +2,21 @@
 
 import logging
 import os
+import re
 from dataclasses import dataclass
+from typing import List, Optional
 
+import pygit2
+from attrs import field
 from pygit2 import RemoteCallbacks, UserPass, clone_repository
 from reqstool_python_decorators.decorators.decorators import Requirements
 
 from reqstool.locations.location import LocationInterface
 
+RE_GIT_TAG_VERSION: re.Pattern[str] = re.compile(
+    r"(?:^v)?(?P\d+\.\d+\.\d+)(?P\S+)?", re.VERBOSE | re.IGNORECASE
+)
+
 
 @Requirements("REQ_002")
 @dataclass(kw_only=True)
@@ -18,24 +26,91 @@ class GitLocation(LocationInterface):
     env_token: str
     path: str
 
-    def _make_available_on_localdisk(self, dst_path: str) -> str:
-        api_token = os.getenv(self.env_token)
+    token: Optional[str] = field(init=False, default=None)
+
+    def __post_init__(self) -> None:
+        # Retrieve token from environment variable
+        self.token = os.getenv(self.env_token) if self.env_token else None
 
+        if self.token:
+            logging.debug("Using token for authentication")
+
+    def _make_available_on_localdisk(self, dst_path: str) -> str:
         if self.branch:
             repo = clone_repository(
-                url=self.url, path=dst_path, checkout_branch=self.branch, callbacks=self.MyRemoteCallbacks(api_token)
+                url=self.url, path=dst_path, checkout_branch=self.branch, callbacks=self.MyRemoteCallbacks(self.token)
             )
         else:
-            repo = clone_repository(url=self.url, path=dst_path, callbacks=self.MyRemoteCallbacks(api_token))
+            repo = clone_repository(url=self.url, path=dst_path, callbacks=self.MyRemoteCallbacks(self.token))
 
         logging.debug(f"Cloned repo {self.url} (branch: {self.branch}) to {repo.workdir}\n")
 
         return repo.workdir
 
+    @staticmethod
+    def _get_all_versions(repo_path: str, token: Optional[str] = None) -> List[str]:
+        """
+        Returns all versions from tags from the repository located at `repo_path` that match the RE_GIT_TAG_VERSION pattern.
+        """
+
+        repo = pygit2.Repository(path=repo_path)
+
+        # If the repository needs authentication for remote references:
+        if repo.remotes:
+            callbacks = self.MyRemoteCallbacks(token)
+            remote = repo.remotes["origin"]
+
+            # Ensure remote references are updated (no fetch, just accessing the tags)
+            remote.fetch(callbacks=callbacks)
+
+        all_versions: list[str] = list()
+
+        for tag in repo.references:
+            if tag.startswith("refs/tags/"):
+                tag_name = tag.split("refs/tags/", 1)[1]
+                if RE_GIT_TAG_VERSION.match(tag_name):
+                    all_versions.append(tag_name)
+
+        if not all_versions:
+            raise Exception(f"No versions found for repo. {repo_path}")
+
+        return all_versions
+
+    def _resolve_version(self) -> str:
+        """Resolve version based on version specifier."""
+
+        if self.version not in ["latest", "latest-stable", "latest-unstable"]:
+            return self.version
+
+        all_versions: List[str] = self._get_all_versions()
+
+        if self.version == "latest":
+            return all_versions[-1]
+
+        is_stable = self.version == "latest-stable"
+
+        resolved_version: Optional[str] = None
+        for v in reversed(all_versions):
+            match: Optional[re.Match[str]] = RE_PEP440_VERSION.search(v)
+
+            if not match:
+                continue
+
+            version: str = match.group("version")
+            extra: str = match.group("extra")
+
+            if is_stable and not extra:
+                resolved_version = version
+            elif not is_stable and extra:
+                resolved_version = f"{version}{extra}"
+
+            if resolved_version:
+                break
+
     class MyRemoteCallbacks(RemoteCallbacks):
-        def __init__(self, api_token):
+        def __init__(self, token):
             self.auth_method = ""  # x-oauth-basic, x-access-token
-            self.api_token = api_token
+            self.token = token
 
         def credentials(self, url, username_from_url, allowed_types):
-            return UserPass(username=self.auth_method, password=self.api_token)
+            return UserPass(username=self.auth_method, password=self.token)
diff --git a/src/reqstool/locations/maven_location.py b/src/reqstool/locations/maven_location.py
index e14073d7..2933d548 100644
--- a/src/reqstool/locations/maven_location.py
+++ b/src/reqstool/locations/maven_location.py
@@ -2,15 +2,14 @@
 
 import logging
 import os
-import sys
 from dataclasses import dataclass, field
 from typing import List, Optional
-from zipfile import ZipFile
 
 from lxml import etree
-from maven_artifact import Artifact, Downloader, RequestException, Resolver
+from maven_artifact import Artifact, RequestException, Resolver
 from reqstool_python_decorators.decorators.decorators import Requirements
 
+from reqstool.common.dataclasses.maven_version import MavenVersion
 from reqstool.locations.location import LocationInterface
 
 
@@ -68,14 +67,19 @@ def _resolve_version(self, resolver: Resolver) -> str:
             return all_versions[-1]
 
         is_stable = self.version == "latest-stable"
-        filtered_versions = [v for v in all_versions if v.endswith("-SNAPSHOT") != is_stable]
+        mv: MavenVersion
+        filtered_versions: List[MavenVersion] = [
+            mv
+            for v in all_versions
+            if (mv := MavenVersion(version=v)) and ((bool(mv.qualifier) or mv.snapshot) != is_stable)
+        ]
 
         # no matching version found
         if not filtered_versions:
             version_type: str = "stable" if is_stable else "unstable"
             raise RequestException(f"No {version_type} versions found for {self.group_id}:{self.artifact_id}")
 
-        return filtered_versions[-1]
+        return filtered_versions[-1].version
 
     def _extract_zip(self, zip_file: str, dst_path: str) -> str:
         """Extract ZIP file and return top level directory."""
diff --git a/src/reqstool/locations/pypi_location.py b/src/reqstool/locations/pypi_location.py
index 36985d43..bfa5f033 100644
--- a/src/reqstool/locations/pypi_location.py
+++ b/src/reqstool/locations/pypi_location.py
@@ -20,7 +20,7 @@
         (?:(?P[0-9]+)!)?                           # epoch
         (?P[0-9]+(?:\.[0-9]+)*)                  # release segment
     )
-    (?P                                            # extra: everything after version
+    (?P                                            # rest: everything after version
         (?P
                                          # pre-release
             [-_\.]?
             (?P(a|b|c|rc|alpha|beta|pre|preview))
@@ -131,30 +131,21 @@ def _resolve_version(self) -> str:
 
         is_stable = self.version == "latest-stable"
 
-        resolved_version: Optional[str] = None
-        for v in reversed(all_versions):
-            match: Optional[re.Match[str]] = RE_PEP440_VERSION.search(v)
-
-            if not match:
-                continue
-
-            version: str = match.group("version")
-            extra: str = match.group("extra")
-
-            if is_stable and not extra:
-                resolved_version = version
-            elif not is_stable and extra:
-                resolved_version = f"{version}{extra}"
-
-            if resolved_version:
-                break
+        filtered_versions: List[re.Match] = [
+            match
+            for v in all_versions
+            if (match := RE_PEP440_VERSION.search(v)) and (bool(match.group("rest")) != is_stable)
+        ]
 
         # no matching version found
-        if not resolved_version:
+        if not filtered_versions:
             version_type = "stable" if is_stable else "unstable"
             raise Exception(f"No {version_type} versions found for {self.package}")
 
-        return resolved_version
+        if is_stable:
+            return str(filtered_versions[-1].group("version"))
+        else:
+            return "".join(filtered_versions[-1].group("version", "rest"))
 
     @staticmethod
     def _get_all_versions(package: str, base_url: str, token: Optional[str] = None) -> List[str]:
diff --git a/tests/unit/reqstool/common/dataclasses/test_maven_versions.py b/tests/unit/reqstool/common/dataclasses/test_maven_versions.py
new file mode 100644
index 00000000..ca453f24
--- /dev/null
+++ b/tests/unit/reqstool/common/dataclasses/test_maven_versions.py
@@ -0,0 +1,53 @@
+# Copyright © LFV
+
+from typing import Optional
+
+import pytest
+
+from reqstool.common.dataclasses.maven_version import MavenVersion
+
+
+@pytest.mark.parametrize(
+    "version_string, expected_major, expected_minor, expected_patch, expected_build_number, expected_qualifier, expected_snapshot",
+    [
+        ("1.2.3-alpha", 1, 2, 3, None, "alpha", False),
+        ("1.2.3-SNAPSHOT", 1, 2, 3, None, None, True),
+        ("1.2.3a1-SNAPSHOT", 1, 2, 3, None, "a1", True),
+        ("1.2.3-beta1-SNAPSHOT", 1, 2, 3, None, "beta1", True),
+        ("1.2.3-b2", 1, 2, 3, None, "b2", False),
+        ("1.2.3-beta3", 1, 2, 3, None, "beta3", False),
+        ("1.2.3-milestone1-SNAPSHOT", 1, 2, 3, None, "milestone1", True),
+        ("1.2.3-rc1-SNAPSHOT", 1, 2, 3, None, "rc1", True),
+        ("1.2.3-whatever", 1, 2, 3, None, "whatever", False),
+        ("1.2.3", 1, 2, 3, None, None, False),
+        ("1.2.3-42", 1, 2, 3, 42, None, False),
+        ("1.2.3-42-SNAPSHOT", 1, 2, 3, 42, None, True),
+    ],
+)
+def test_version_parsing(
+    version_string: str,
+    expected_major: int,
+    expected_minor: int,
+    expected_patch: int,
+    expected_build_number: Optional[int],
+    expected_qualifier: Optional[str],
+    expected_snapshot: bool,
+) -> None:
+    # Instantiate MavenVersionInformation
+    version_info = MavenVersion(version_string)
+
+    # Check that the parsed version matches the expected values
+    assert version_info.major == expected_major
+    assert version_info.minor == expected_minor
+    assert version_info.patch == expected_patch
+    assert version_info.build_number == expected_build_number
+    assert version_info.qualifier == expected_qualifier
+    assert version_info.snapshot == expected_snapshot
+
+
+def test_invalid_version() -> None:
+    # Test invalid version format handling (missing minor/patch)
+    with pytest.raises(ValueError):
+        MavenVersion("1.2")
+    with pytest.raises(ValueError):
+        MavenVersion("1.2.3.4")

From da395ee50d9f069fbe396416cfff25e199b5caa4 Mon Sep 17 00:00:00 2001
From: Jimisola Laursen 
Date: Tue, 14 Jan 2025 11:29:27 +0100
Subject: [PATCH 5/6] fix: WIP various

---
 pyproject.toml                                |  9 +-
 .../common/dataclasses/maven_version.py       |  3 +-
 src/reqstool/locations/maven_location.py      | 85 ++++++++++---------
 src/reqstool/locations/pypi_location.py       | 21 ++---
 .../common/dataclasses/test_maven_versions.py |  3 +-
 5 files changed, 66 insertions(+), 55 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index 45387418..d964fe94 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -41,23 +41,24 @@ classifiers = [
 requires-python = ">=3.10"
 
 dependencies = [
+    "beautifulsoup4==4.12.3",
     "colorama==0.4.6",
     "distlib==0.3.9",
     "Jinja2==3.1.4",
     "jsonpickle==4.0.1",
     "jsonschema[format-nongpl]==4.23.0",
     "lark==1.2.2",
+    "lxml-stubs==0.5.1",
     "maven-artifact==0.3.4",
+    "packaging==24.2",
     "pygit2==1.16.0",
     "referencing==0.35.1",
+    "reqstool-python-decorators==0.0.5",
     "requests-file==2.1.0",
+    "requests==2.32.3",
     "ruamel.yaml==0.18.6",
     "tabulate==0.9.0",
     "xmldict==0.4.1",
-    "reqstool-python-decorators==0.0.5",
-    "packaging==24.2",
-    "requests==2.32.3",
-    "beautifulsoup4==4.12.3",
 ]
 
 [project.scripts]
diff --git a/src/reqstool/common/dataclasses/maven_version.py b/src/reqstool/common/dataclasses/maven_version.py
index 6d09d7ba..8aff70be 100644
--- a/src/reqstool/common/dataclasses/maven_version.py
+++ b/src/reqstool/common/dataclasses/maven_version.py
@@ -16,7 +16,8 @@ class MavenVersion:
     snapshot: bool = field(init=False, default=False)  # New field for SNAPSHOT detection
 
     PATTERN_SEMANTIC_VERSION: re.Pattern[str] = re.compile(
-        "^(?P\d+)\.(?P\d+)\.(?P\d+)(?:-(?P\d+))?(?:[-.]?(?P(?!SNAPSHOT$)[a-zA-Z][a-zA-Z0-9]*))?(?:-(?PSNAPSHOT))?$"
+        "^(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(?:-(?P\\d+))?(?:[-.]?(?P"
+        "(?!SNAPSHOT$)[a-zA-Z][a-zA-Z0-9]*))?(?:-(?PSNAPSHOT))?$"
     )
 
     def __post_init__(self) -> None:
diff --git a/src/reqstool/locations/maven_location.py b/src/reqstool/locations/maven_location.py
index 2933d548..4981dc58 100644
--- a/src/reqstool/locations/maven_location.py
+++ b/src/reqstool/locations/maven_location.py
@@ -2,11 +2,13 @@
 
 import logging
 import os
+import sys
 from dataclasses import dataclass, field
 from typing import List, Optional
+from zipfile import ZipFile
 
 from lxml import etree
-from maven_artifact import Artifact, RequestException, Resolver
+from maven_artifact import Artifact, Downloader, RequestException, Resolver
 from reqstool_python_decorators.decorators.decorators import Requirements
 
 from reqstool.common.dataclasses.maven_version import MavenVersion
@@ -31,6 +33,37 @@ def __post_init__(self) -> None:
         if self.token:
             logging.debug("Using OAuth Bearer token for authentication")
 
+    def _make_available_on_localdisk(self, dst_path: str) -> str:
+        downloader = Downloader(base=self.url, token=self.token)
+        resolver = downloader.resolver
+
+        try:
+            resolved_version: str = self._resolve_version(resolver)
+
+            artifact: Artifact = Artifact(
+                group_id=self.group_id,
+                artifact_id=self.artifact_id,
+                version=resolved_version,
+                classifier=self.classifier,
+                extension="zip",
+            )
+            resolved_artifact: Artifact = resolver.resolve(artifact)
+
+            if resolved_artifact.version != resolved_version:
+                logging.debug(f"Resolved version '{self.version}' to: {resolved_artifact.version}")
+
+            if not downloader.download(resolved_artifact, filename=dst_path):
+                raise RequestException(f"Error downloading artifact {resolved_artifact} from: {self.url}")
+
+            return self._extract_zip(resolved_artifact.get_filename(dst_path), dst_path)
+
+        except RequestException as e:
+            logging.fatal(str(e))
+            sys.exit(1)
+        except Exception as e:
+            logging.fatal(f"Unexpected error: {str(e)}")
+            sys.exit(1)
+
     def _get_all_versions(self, resolver: Resolver, artifact: Artifact) -> List[str]:
         """Get all available versions for the artifact."""
         try:
@@ -67,19 +100,24 @@ def _resolve_version(self, resolver: Resolver) -> str:
             return all_versions[-1]
 
         is_stable = self.version == "latest-stable"
-        mv: MavenVersion
-        filtered_versions: List[MavenVersion] = [
-            mv
-            for v in all_versions
-            if (mv := MavenVersion(version=v)) and ((bool(mv.qualifier) or mv.snapshot) != is_stable)
-        ]
+        filtered_versions: List[str] = self._filter_versions(all_versions=all_versions, stable_versions=is_stable)
 
         # no matching version found
         if not filtered_versions:
             version_type: str = "stable" if is_stable else "unstable"
             raise RequestException(f"No {version_type} versions found for {self.group_id}:{self.artifact_id}")
 
-        return filtered_versions[-1].version
+        return filtered_versions[-1]
+
+    @staticmethod
+    def _filter_versions(all_versions: List[str], stable_versions: bool) -> List[str]:
+        filtered_versions: List[str] = [
+            mv.version
+            for v in all_versions
+            if (mv := MavenVersion(version=v)) and ((bool(mv.qualifier) or mv.snapshot) != stable_versions)
+        ]
+
+        return filtered_versions
 
     def _extract_zip(self, zip_file: str, dst_path: str) -> str:
         """Extract ZIP file and return top level directory."""
@@ -96,34 +134,3 @@ def _extract_zip(self, zip_file: str, dst_path: str) -> str:
         top_level_dir = os.path.join(dst_path, top_level_dirs.pop())
         logging.debug(f"Unzipped {zip_file} to {top_level_dir}")
         return top_level_dir
-
-    def _make_available_on_localdisk(self, dst_path: str) -> str:
-        downloader = Downloader(base=self.url, token=self.token)
-        resolver = downloader.resolver
-
-        try:
-            resolved_version: str = self._resolve_version(resolver)
-
-            artifact: Artifact = Artifact(
-                group_id=self.group_id,
-                artifact_id=self.artifact_id,
-                version=resolved_version,
-                classifier=self.classifier,
-                extension="zip",
-            )
-            resolved_artifact: Artifact = resolver.resolve(artifact)
-
-            if resolved_artifact.version != resolved_version:
-                logging.debug(f"Resolved version '{self.version}' to: {resolved_artifact.version}")
-
-            if not downloader.download(resolved_artifact, filename=dst_path):
-                raise RequestException(f"Error downloading artifact {resolved_artifact} from: {self.url}")
-
-            return self._extract_zip(resolved_artifact.get_filename(dst_path), dst_path)
-
-        except RequestException as e:
-            logging.fatal(str(e))
-            sys.exit(1)
-        except Exception as e:
-            logging.fatal(f"Unexpected error: {str(e)}")
-            sys.exit(1)
diff --git a/src/reqstool/locations/pypi_location.py b/src/reqstool/locations/pypi_location.py
index bfa5f033..1646ce3b 100644
--- a/src/reqstool/locations/pypi_location.py
+++ b/src/reqstool/locations/pypi_location.py
@@ -131,21 +131,22 @@ def _resolve_version(self) -> str:
 
         is_stable = self.version == "latest-stable"
 
-        filtered_versions: List[re.Match] = [
-            match
-            for v in all_versions
-            if (match := RE_PEP440_VERSION.search(v)) and (bool(match.group("rest")) != is_stable)
-        ]
+        filtered_versions: List[str] = self._filter_versions(all_versions=all_versions, stable_versions=is_stable)
 
         # no matching version found
         if not filtered_versions:
             version_type = "stable" if is_stable else "unstable"
-            raise Exception(f"No {version_type} versions found for {self.package}")
+            raise ValueError(f"No {version_type} versions found for {self.package}")
+
+        return filtered_versions[-1]
 
-        if is_stable:
-            return str(filtered_versions[-1].group("version"))
-        else:
-            return "".join(filtered_versions[-1].group("version", "rest"))
+    @staticmethod
+    def _filter_versions(all_versions: List[str], stable_versions: bool) -> List[str]:
+        return [
+            v
+            for v in all_versions
+            if (match := RE_PEP440_VERSION.search(v)) and (bool(match.group("rest")) != stable_versions)
+        ]
 
     @staticmethod
     def _get_all_versions(package: str, base_url: str, token: Optional[str] = None) -> List[str]:
diff --git a/tests/unit/reqstool/common/dataclasses/test_maven_versions.py b/tests/unit/reqstool/common/dataclasses/test_maven_versions.py
index ca453f24..75159c32 100644
--- a/tests/unit/reqstool/common/dataclasses/test_maven_versions.py
+++ b/tests/unit/reqstool/common/dataclasses/test_maven_versions.py
@@ -8,7 +8,8 @@
 
 
 @pytest.mark.parametrize(
-    "version_string, expected_major, expected_minor, expected_patch, expected_build_number, expected_qualifier, expected_snapshot",
+    "version_string, expected_major, expected_minor, expected_patch, "
+    "expected_build_number, expected_qualifier, expected_snapshot",
     [
         ("1.2.3-alpha", 1, 2, 3, None, "alpha", False),
         ("1.2.3-SNAPSHOT", 1, 2, 3, None, None, True),

From 23c6553408a0e841456df5fe8796d62261dd3537 Mon Sep 17 00:00:00 2001
From: Jimisola Laursen 
Date: Tue, 14 Jan 2025 16:54:46 +0100
Subject: [PATCH 6/6] fix: WIP various

---
 .../common/dataclasses/maven_version.py       |  2 +-
 src/reqstool/locations/git_location.py        | 89 ++++++++++---------
 src/reqstool/locations/pypi_location.py       |  4 +-
 .../reqstool/locations/test_git_location.py   | 11 +++
 4 files changed, 63 insertions(+), 43 deletions(-)

diff --git a/src/reqstool/common/dataclasses/maven_version.py b/src/reqstool/common/dataclasses/maven_version.py
index 8aff70be..97718ef0 100644
--- a/src/reqstool/common/dataclasses/maven_version.py
+++ b/src/reqstool/common/dataclasses/maven_version.py
@@ -16,7 +16,7 @@ class MavenVersion:
     snapshot: bool = field(init=False, default=False)  # New field for SNAPSHOT detection
 
     PATTERN_SEMANTIC_VERSION: re.Pattern[str] = re.compile(
-        "^(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(?:-(?P\\d+))?(?:[-.]?(?P"
+        "^(?P\d+)\.(?P\d+)\.(?P\d+)(?:-(?P\d+))?(?:[-.]?(?P"
         "(?!SNAPSHOT$)[a-zA-Z][a-zA-Z0-9]*))?(?:-(?PSNAPSHOT))?$"
     )
 
diff --git a/src/reqstool/locations/git_location.py b/src/reqstool/locations/git_location.py
index 2257c099..7c377547 100644
--- a/src/reqstool/locations/git_location.py
+++ b/src/reqstool/locations/git_location.py
@@ -1,20 +1,26 @@
 # Copyright © LFV
 
+# type: ignore[no-untyped-call]
+
 import logging
 import os
 import re
 from dataclasses import dataclass
-from typing import List, Optional
+from typing import List, Optional, Union
 
 import pygit2
 from attrs import field
-from pygit2 import RemoteCallbacks, UserPass, clone_repository
+from pygit2 import CredentialType, RemoteCallbacks, UserPass, clone_repository
 from reqstool_python_decorators.decorators.decorators import Requirements
 
 from reqstool.locations.location import LocationInterface
 
 RE_GIT_TAG_VERSION: re.Pattern[str] = re.compile(
-    r"(?:^v)?(?P\d+\.\d+\.\d+)(?P\S+)?", re.VERBOSE | re.IGNORECASE
+    r"^refs\/tags\/(?P(?:v)?\d+\.\d+\.\d+\S*)$", re.VERBOSE | re.IGNORECASE
+)
+
+RE_SEMANTIC_VERSION_PLUS_EXTRA: re.Pattern[str] = re.compile(
+    r"^(?P(?P(?:v)?\d+\.\d+\.\d+)(?P\S+)?)$", re.VERBOSE | re.IGNORECASE
 )
 
 
@@ -38,14 +44,14 @@ def __post_init__(self) -> None:
     def _make_available_on_localdisk(self, dst_path: str) -> str:
         if self.branch:
             repo = clone_repository(
-                url=self.url, path=dst_path, checkout_branch=self.branch, callbacks=self.MyRemoteCallbacks(self.token)
+                url=self.url, path=dst_path, checkout_branch=self.branch, callbacks=MyRemoteCallbacks(self.token)
             )
         else:
-            repo = clone_repository(url=self.url, path=dst_path, callbacks=self.MyRemoteCallbacks(self.token))
+            repo = clone_repository(url=self.url, path=dst_path, callbacks=MyRemoteCallbacks(self.token))
 
         logging.debug(f"Cloned repo {self.url} (branch: {self.branch}) to {repo.workdir}\n")
 
-        return repo.workdir
+        return str(repo.workdir)
 
     @staticmethod
     def _get_all_versions(repo_path: str, token: Optional[str] = None) -> List[str]:
@@ -56,23 +62,21 @@ def _get_all_versions(repo_path: str, token: Optional[str] = None) -> List[str]:
         repo = pygit2.Repository(path=repo_path)
 
         # If the repository needs authentication for remote references:
-        if repo.remotes:
-            callbacks = self.MyRemoteCallbacks(token)
-            remote = repo.remotes["origin"]
+        # if repo.remotes:
+        #     callbacks = MyRemoteCallbacks(token)
+        #     remote = repo.remotes["origin"]
 
-            # Ensure remote references are updated (no fetch, just accessing the tags)
-            remote.fetch(callbacks=callbacks)
+        #     # Ensure remote references are updated (no fetch, just accessing the tags)
+        #     remote.fetch(callbacks=callbacks)
 
         all_versions: list[str] = list()
 
-        for tag in repo.references:
-            if tag.startswith("refs/tags/"):
-                tag_name = tag.split("refs/tags/", 1)[1]
-                if RE_GIT_TAG_VERSION.match(tag_name):
-                    all_versions.append(tag_name)
+        for ref in repo.references:
+            if match := RE_GIT_TAG_VERSION.match(ref):
+                all_versions.append(match.group("version"))
 
         if not all_versions:
-            raise Exception(f"No versions found for repo. {repo_path}")
+            raise ValueError(f"No versions found for repo. {repo_path}")
 
         return all_versions
 
@@ -82,35 +86,40 @@ def _resolve_version(self) -> str:
         if self.version not in ["latest", "latest-stable", "latest-unstable"]:
             return self.version
 
-        all_versions: List[str] = self._get_all_versions()
+        all_versions: List[str] = self._get_all_versions(package=self.package, base_url=self.url, token=self.token)
 
         if self.version == "latest":
             return all_versions[-1]
 
         is_stable = self.version == "latest-stable"
 
-        resolved_version: Optional[str] = None
-        for v in reversed(all_versions):
-            match: Optional[re.Match[str]] = RE_PEP440_VERSION.search(v)
-
-            if not match:
-                continue
+        filtered_versions: List[str] = self._filter_versions(all_versions=all_versions, stable_versions=is_stable)
 
-            version: str = match.group("version")
-            extra: str = match.group("extra")
+        # no matching version found
+        if not filtered_versions:
+            version_type = "stable" if is_stable else "unstable"
+            raise ValueError(f"No {version_type} versions found for {self.package}")
 
-            if is_stable and not extra:
-                resolved_version = version
-            elif not is_stable and extra:
-                resolved_version = f"{version}{extra}"
+        return filtered_versions[-1]
 
-            if resolved_version:
-                break
-
-    class MyRemoteCallbacks(RemoteCallbacks):
-        def __init__(self, token):
-            self.auth_method = ""  # x-oauth-basic, x-access-token
-            self.token = token
-
-        def credentials(self, url, username_from_url, allowed_types):
-            return UserPass(username=self.auth_method, password=self.token)
+    @staticmethod
+    def _filter_versions(all_versions: List[str], stable_versions: bool) -> List[str]:
+        return [
+            v
+            for v in all_versions
+            if (match := RE_SEMANTIC_VERSION_PLUS_EXTRA.search(v)) and (bool(match.group("rest")) != stable_versions)
+        ]
+
+
+class MyRemoteCallbacks(RemoteCallbacks):
+    def __init__(self, token: str):
+        self.auth_method = ""  # x-oauth-basic, x-access-token
+        self.token = token
+
+    def credentials(
+        self,
+        url: str,
+        username_from_url: Union[str, None],
+        allowed_types: CredentialType,
+    ) -> UserPass:
+        return UserPass(username=self.auth_method, password=self.token)
diff --git a/src/reqstool/locations/pypi_location.py b/src/reqstool/locations/pypi_location.py
index 1646ce3b..e40091c8 100644
--- a/src/reqstool/locations/pypi_location.py
+++ b/src/reqstool/locations/pypi_location.py
@@ -16,7 +16,7 @@
 # https://packaging.python.org/en/latest/specifications/version-specifiers/#version-specifiers-regex
 VERSION_PATTERN = r"""
     v?
-    (?P                                          # Combined version (epoch + release)
+    (?P                                          # Combined version (epoch + release)
         (?:(?P[0-9]+)!)?                           # epoch
         (?P[0-9]+(?:\.[0-9]+)*)                  # release segment
     )
@@ -189,7 +189,7 @@ def _get_all_versions(package: str, base_url: str, token: Optional[str] = None)
                 continue
 
             # Extract semantic version value if found, otherwise set to "unknown"
-            version = match.group("version")
+            version = match.group("semantic")
 
             all_versions.append(version)
 
diff --git a/tests/unit/reqstool/locations/test_git_location.py b/tests/unit/reqstool/locations/test_git_location.py
index 9d75910b..0a6d44e7 100644
--- a/tests/unit/reqstool/locations/test_git_location.py
+++ b/tests/unit/reqstool/locations/test_git_location.py
@@ -1,5 +1,7 @@
 # Copyright © LFV
 
+import os
+
 from reqstool.locations.git_location import GitLocation
 
 
@@ -29,3 +31,12 @@ def test_git_location(resource_funcname_rootdir_w_path: str) -> None:
     assert git_location.url == "https://git.example.com/repo.git"
     assert git_location.branch == "test"
     assert git_location.path == PATH
+
+
+def test_git_location(resource_funcname_rootdir_w_path: str) -> None:
+
+    x = "/home/u30576/dev/clones/github/Luftfartsverket/reqstool-client"
+
+    all_versions = GitLocation._get_all_versions(repo_path=x, token=os.getenv("GITLAB_TOKEN"))
+
+    assert all_versions is not None