Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import logging
from pathlib import Path

from qubership_pipelines_common_library.v2.artifacts_finder.comparers.default_version_comparer import DefaultVersionComparer
from qubership_pipelines_common_library.v2.artifacts_finder.model.artifact import Artifact
from qubership_pipelines_common_library.v2.artifacts_finder.model.artifact_provider import ArtifactProvider
from qubership_pipelines_common_library.v2.artifacts_finder.model.comparer import Comparer


class ArtifactFinder:
Expand All @@ -12,10 +14,13 @@ class ArtifactFinder:

Supports different repository providers: Artifactory, Nexus, AWS, GCP, Azure

Providers might slightly differ in functionality, refer to the Provider docs (e.g. only Artifactory currently supports wildcard version search)
Providers might slightly differ in functionality, refer to the Provider docs

Provides different auth methods for Cloud Providers, implementing `CloudCredentialsProvider` interface

When searching with ``latest=True``, the newest version is chosen by a pluggable comparer.
Pass a ``Comparer`` instance (e.g. ``SimpleVersionComparer()``); when omitted, a ``DefaultVersionComparer`` is used.

Start by initializing this client with one of implementations:
``finder = ArtifactFinder(artifact_provider=ArtifactoryProvider(registry_url="https://our_url", username="user", password="password"))``

Expand All @@ -34,19 +39,24 @@ class ArtifactFinder:
```
"""

def __init__(self, artifact_provider: ArtifactProvider, **kwargs):
def __init__(self, artifact_provider: ArtifactProvider, comparer: Comparer = None, **kwargs):
if not artifact_provider:
raise Exception("Initialize ArtifactFinder with one of registry artifact providers first!")
self.provider = artifact_provider
self.comparer = comparer if comparer is not None else DefaultVersionComparer()

def find_artifact_urls(self, artifact_id: str = None, version: str = None, group_id: str = None,
extension: str = "jar", artifact: Artifact = None, latest: bool = False) -> list[str]:
"""
:param version: Supports asterisk-wildcard (e.g. "main-*-RELEASE")
:param latest: Will try to determine and return only the latest version among all versions found when searching for wildcard-containing version
"""
if not artifact:
artifact = Artifact(group_id=group_id, artifact_id=artifact_id, version=version, extension=extension)
if not artifact.artifact_id or not artifact.version:
raise Exception("Artifact 'artifact_id' and 'version' must be specified!")
logging.debug(f"Searching for '{artifact.artifact_id}:{artifact.version}' in {self.provider.get_provider_name()}...")
return self.provider.search_artifacts(artifact=artifact, latest=latest)
return self.provider.search_artifacts(artifact=artifact, latest=latest, comparer=self.comparer)

def download_artifact(self, resource_url: str, local_path: str | Path, artifact: Artifact = None):
from qubership_pipelines_common_library.v1.utils.utils_file import UtilsFile
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import re
from dataclasses import dataclass
from typing import Literal

from qubership_pipelines_common_library.v2.artifacts_finder.model.comparer import Comparer

CompareResult = Literal[-2, -1, 0, 1, 2]

# Matches the first occurrence of MAJOR.MINOR.PATCH anywhere in a string.
# Negative look-around prevents matching sub-sequences of longer dotted
# numbers such as "1.2.3.4".
_SEMVER_RE = re.compile(r"(?<!\d)(\d+)\.(\d+)\.(\d+)(?!\.\d)")


class DefaultVersionComparer(Comparer):
def __init__(self):
self._delegate = VersionComparer()

def compare(self, v1: str, v2: str) -> int:
return self._delegate.compare(v1, v2)


@dataclass(frozen=True)
class _ParsedVersion:
prefix: str
semver: tuple[int, int, int]
suffix: str


class VersionComparer:
def compare(self, v1: str, v2: str) -> CompareResult:
"""Compare two version strings.

Returns:
-2 if v1 < v2 and SEMVER major version differs (upgrade)
-1 if v1 < v2 and SEMVER major is the same (or fallback lex)
0 if v1 == v2
+1 if v1 > v2 and SEMVER major is the same (or fallback lex)
+2 if v1 > v2 and SEMVER major version differs (downgrade)

Comparison strategy when both strings contain MAJOR.MINOR.PATCH:
1. Compare the substrings *before* the SEMVER lexicographically.
2. If equal, compare the SEMVER triplets (major diff - ±2, minor/patch diff - ±1).
3. If equal, compare the substrings *after* the SEMVER lexicographically.

If at least one string has no embedded SEMVER, fall back to a plain
lexicographic comparison of the full strings.
"""
v1, v2 = v1.strip(), v2.strip()

p1 = self._parse(v1)
p2 = self._parse(v2)

if p1 is not None and p2 is not None:
r = self._cmp(p1.prefix, p2.prefix)
if r != 0:
return r
r = self._cmp_semver(p1.semver, p2.semver)
if r != 0:
return r
return self._cmp(p1.suffix, p2.suffix)

return self._cmp(v1, v2)

def has_semver(self, version: str) -> bool:
"""Return True if *version* contains an embedded MAJOR.MINOR.PATCH."""
return self._parse(version.strip()) is not None

@staticmethod
def _parse(version: str) -> _ParsedVersion | None:
"""Split *version* into (prefix, semver-triplet, suffix), or None."""
m = _SEMVER_RE.search(version)
if m is None:
return None
return _ParsedVersion(
prefix=version[: m.start()],
semver=(int(m.group(1)), int(m.group(2)), int(m.group(3))),
suffix=version[m.end() :],
)

@staticmethod
def _cmp_semver(
parts1: tuple[int, int, int], parts2: tuple[int, int, int]
) -> CompareResult:
"""Compare two SEMVER triplets; major difference returns ±2."""
maj1, min1, pat1 = parts1
maj2, min2, pat2 = parts2
if maj1 != maj2:
return -2 if maj1 < maj2 else 2
if (min1, pat1) < (min2, pat2):
return -1
if (min1, pat1) > (min2, pat2):
return 1
return 0

@staticmethod
def _cmp(a, b) -> CompareResult:
"""Generic three-way compare for any comparable type."""
if a < b:
return -1
if a > b:
return 1
return 0
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import re

from qubership_pipelines_common_library.v2.artifacts_finder.model.comparer import Comparer


class SimpleVersionComparer(Comparer):
"""Orders versions by their embedded SEMVER triplet, then by snapshot timestamp/build number, then by the raw string as a final tie-breaker."""

def compare(self, v1: str, v2: str) -> int:
k1, k2 = self._sort_key(v1), self._sort_key(v2)
if k1 < k2:
return -1
if k1 > k2:
return 1
return 0

@staticmethod
def _sort_key(version: str):
semver = re.search(r'(\d+)\.(\d+)\.(\d+)', version)
semver_key = tuple(int(part) for part in semver.groups()) if semver else (-1, -1, -1)
timestamp = re.search(r'(\d{8})\.(\d{6})(?:-(\d+))?', version)
timestamp_key = (int(timestamp.group(1)), int(timestamp.group(2)), int(timestamp.group(3) or 0)) if timestamp else (-1, -1, -1)
return semver_key, timestamp_key, version
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from abc import ABC, abstractmethod


class Comparer(ABC):
"""Pluggable version-comparison interface used by ArtifactFinder to pick the 'latest' version.

``compare(v1, v2)`` must return:
a negative number if v1 sorts before v2 (v1 is older),
0 if v1 and v2 are considered equal,
a positive number if v1 sorts after v2 (v1 is newer).
Only the sign of the result is used; the magnitude is ignored.
"""

@abstractmethod
def compare(self, v1: str, v2: str) -> int:
...
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ def __init__(self, registry_url: str, username: str = None, password: str = None
def download_artifact(self, resource_url: str, local_path: str | Path, **kwargs) -> None:
return self.generic_download(resource_url=resource_url, local_path=local_path)

def search_artifacts(self, artifact: Artifact, latest: bool = False, **kwargs) -> list[str]:
def search_artifacts(self, artifact: Artifact, latest: bool = False, comparer=None, **kwargs) -> list[str]:
if artifact.has_version_wildcard():
return self._search_wildcard_versions(artifact, latest=latest)
return self._search_wildcard_versions(artifact, latest=latest, comparer=comparer)

timestamp_version_match = re.match(self.TIMESTAMP_VERSION_PATTERN, artifact.version)
if timestamp_version_match:
Expand All @@ -56,7 +56,7 @@ def search_artifacts(self, artifact: Artifact, latest: bool = False, **kwargs) -
def get_provider_name(self) -> str:
return "artifactory"

def _search_wildcard_versions(self, artifact: Artifact, latest: bool = False) -> list[str]:
def _search_wildcard_versions(self, artifact: Artifact, latest: bool = False, comparer=None) -> list[str]:
search_params = {
**({"g": artifact.group_id} if artifact.group_id else {}),
"a": artifact.artifact_id,
Expand All @@ -65,15 +65,15 @@ def _search_wildcard_versions(self, artifact: Artifact, latest: bool = False) ->
}
results = self._gavc_search(search_params, artifact.artifact_id)
# client-side pattern guard
version_pattern = self._wildcard_to_regex(artifact.version)
matches = [
result for result in results
version_pattern = ArtifactFinderUtils.wildcard_to_regex(artifact.version)
download_urls = [
result["downloadUri"] for result in results
if result["ext"] == artifact.extension and version_pattern.fullmatch(result["version"])
]
if latest:
latest_match = self._select_latest(matches)
return [latest_match["downloadUri"]] if latest_match else []
return [result["downloadUri"] for result in matches]
latest_url = ArtifactFinderUtils.select_latest([(url.rsplit("/", 1)[-1], url) for url in download_urls], comparer)
return [latest_url] if latest_url else []
return download_urls

def _gavc_search(self, search_params: dict, artifact_id: str) -> list[dict]:
search_api_url = f"{self.registry_url}/api/search/gavc"
Expand All @@ -82,12 +82,3 @@ def _gavc_search(self, search_params: dict, artifact_id: str) -> list[dict]:
if response.status_code != 200:
raise Exception(f"Could not find '{artifact_id}' - search request returned {response.status_code}!")
return response.json().get("results", [])

@staticmethod
def _wildcard_to_regex(pattern: str) -> re.Pattern:
return re.compile(".*".join(re.escape(part) for part in pattern.split("*")))

@staticmethod
def _select_latest(matches: list[dict]) -> dict | None:
# Best-effort 'latest' among gavc results, comparing by semver, then snapshot/release timestamp
return max(matches, key=lambda match: ArtifactFinderUtils.version_sort_key(match["downloadUri"].rsplit("/", 1)[-1])) if matches else None
Loading
Loading