diff --git a/pyproject.toml b/pyproject.toml index 33bd23ee..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] @@ -69,9 +70,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/common/dataclasses/maven_version.py b/src/reqstool/common/dataclasses/maven_version.py new file mode 100644 index 00000000..97718ef0 --- /dev/null +++ b/src/reqstool/common/dataclasses/maven_version.py @@ -0,0 +1,39 @@ +# 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..7c377547 100644 --- a/src/reqstool/locations/git_location.py +++ b/src/reqstool/locations/git_location.py @@ -1,14 +1,28 @@ # Copyright © LFV +# type: ignore[no-untyped-call] + import logging import os +import re from dataclasses import dataclass +from typing import List, Optional, Union -from pygit2 import RemoteCallbacks, UserPass, clone_repository +import pygit2 +from attrs import field +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"^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 +) + @Requirements("REQ_002") @dataclass(kw_only=True) @@ -18,24 +32,94 @@ 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=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=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]: + """ + 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 = 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 ref in repo.references: + if match := RE_GIT_TAG_VERSION.match(ref): + all_versions.append(match.group("version")) + + if not all_versions: + raise ValueError(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(package=self.package, base_url=self.url, token=self.token) + + if self.version == "latest": + return all_versions[-1] + + is_stable = self.version == "latest-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 ValueError(f"No {version_type} versions found for {self.package}") + + return filtered_versions[-1] + + @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, api_token): - self.auth_method = "" # x-oauth-basic, x-access-token - self.api_token = api_token +class MyRemoteCallbacks(RemoteCallbacks): + def __init__(self, token: str): + 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.api_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/maven_location.py b/src/reqstool/locations/maven_location.py index bb6ec3f9..4981dc58 100644 --- a/src/reqstool/locations/maven_location.py +++ b/src/reqstool/locations/maven_location.py @@ -4,12 +4,14 @@ import os import sys from dataclasses import dataclass, field -from typing import Optional +from typing import List, Optional from zipfile import ZipFile -from maven_artifact import Artifact, Downloader, RequestException +from lxml import etree +from maven_artifact import Artifact, Downloader, RequestException, Resolver from reqstool_python_decorators.decorators.decorators import Requirements +from reqstool.common.dataclasses.maven_version import MavenVersion from reqstool.locations.location import LocationInterface @@ -19,53 +21,116 @@ 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 + env_token: Optional[str] + token: Optional[str] = field(init=False, default=None) - def _make_available_on_localdisk(self, dst_path: str): - token = os.getenv(self.env_token) + def __post_init__(self) -> None: + # Retrieve token from environment variable + self.token = os.getenv(self.env_token) if self.env_token else None - # assume OAuth Bearer, see: https://georgearisty.dev/posts/oauth2-token-bearer-usage/ - downloader = Downloader(base=self.url, token=token) + if self.token: + logging.debug("Using OAuth Bearer token for authentication") - artifact = Artifact( - group_id=self.group_id, - artifact_id=self.artifact_id, - version=self.version, - classifier=self.classifier, - extension="zip", - ) - - logging.debug(f"Downloading {artifact} from {self.url} to {dst_path}") + def _make_available_on_localdisk(self, dst_path: str) -> str: + downloader = Downloader(base=self.url, token=self.token) + resolver = downloader.resolver try: - if not downloader.download(artifact, filename=dst_path): - raise RequestException(f"Error downloading artifact {artifact} from: {self.url}") + 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(e.msg) + logging.fatal(str(e)) + sys.exit(1) + except Exception as e: + logging.fatal(f"Unexpected error: {str(e)}") sys.exit(1) - logging.debug(f"Unzipping {artifact.get_filename(dst_path)} to {dst_path}\n") + 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: 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" - 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} + xml = resolver.requestor.request( + resolver.base + path, resolver._onFail, lambda r: etree.fromstring(r.content) + ) + all_versions: List[str] = xml.xpath("/metadata/versioning/versions/version/text()") + + if not all_versions: + raise RequestException(f"No versions found for {artifact}") + + return all_versions + except Exception as e: + raise RequestException(f"Failed to get versions for {artifact}: {str(e)}") + + 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 + base_artifact: Artifact = Artifact( + group_id=self.group_id, version=self.version, artifact_id=self.artifact_id, classifier=self.classifier + ) + + all_versions: List[str] = self._get_all_versions(resolver, base_artifact) + + if self.version == "latest": + return all_versions[-1] + + is_stable = self.version == "latest-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] + + @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.""" + 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()) - - # os.remove(artifact.get_filename(dst_path)) - - logging.debug(f"Unzipped {artifact.get_filename(dst_path)} to {top_level_dir}\n") - + logging.debug(f"Unzipped {zip_file} to {top_level_dir}") return top_level_dir diff --git a/src/reqstool/locations/pypi_location.py b/src/reqstool/locations/pypi_location.py index 2fdce1b1..e40091c8 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 # rest: 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)
-
-        if token:
-            logging.debug("Using OAuth Bearer token for authentication")
+        resolved_version: str = self._resolve_version()
 
-        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,88 @@ 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"
+
+        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 ValueError(f"No {version_type} versions found for {self.package}")
+
+        return filtered_versions[-1]
+
+    @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]:
+        """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("semantic")
+
+            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/common/dataclasses/test_maven_versions.py b/tests/unit/reqstool/common/dataclasses/test_maven_versions.py
new file mode 100644
index 00000000..75159c32
--- /dev/null
+++ b/tests/unit/reqstool/common/dataclasses/test_maven_versions.py
@@ -0,0 +1,54 @@
+# 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")
diff --git a/tests/unit/reqstool/locations/test_git_location.py b/tests/unit/reqstool/locations/test_git_location.py
index ed02c2c2..0a6d44e7 100644
--- a/tests/unit/reqstool/locations/test_git_location.py
+++ b/tests/unit/reqstool/locations/test_git_location.py
@@ -1,9 +1,11 @@
 # Copyright © LFV
 
+import os
+
 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(
@@ -29,3 +31,12 @@ def test_git_location(resource_funcname_rootdir_w_path):
     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
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..99a9e1d8
--- /dev/null
+++ b/tests/unit/reqstool/locations/test_maven_location.py
@@ -0,0 +1,51 @@
+# Copyright © LFV
+
+from maven_artifact import Downloader, Resolver
+
+from reqstool.locations.maven_location import MavenLocation
+
+
+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)
+
+    version = maven_location._resolve_version(resolver=resolver)
+
+    assert version == "0.1.0"
+
+    maven_location.version = "latest"
+
+    latest_version = maven_location._resolve_version(
+        resolver=resolver,
+    )
+
+    # 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"
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