diff --git a/qubership_pipelines_common_library/v2/artifacts_finder/artifact_finder.py b/qubership_pipelines_common_library/v2/artifacts_finder/artifact_finder.py index f9dcf98..67d2ea2 100644 --- a/qubership_pipelines_common_library/v2/artifacts_finder/artifact_finder.py +++ b/qubership_pipelines_common_library/v2/artifacts_finder/artifact_finder.py @@ -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: @@ -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"))`` @@ -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 diff --git a/qubership_pipelines_common_library/v2/artifacts_finder/comparers/__init__.py b/qubership_pipelines_common_library/v2/artifacts_finder/comparers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/qubership_pipelines_common_library/v2/artifacts_finder/comparers/default_version_comparer.py b/qubership_pipelines_common_library/v2/artifacts_finder/comparers/default_version_comparer.py new file mode 100644 index 0000000..04101bd --- /dev/null +++ b/qubership_pipelines_common_library/v2/artifacts_finder/comparers/default_version_comparer.py @@ -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"(? 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 diff --git a/qubership_pipelines_common_library/v2/artifacts_finder/comparers/simple_version_comparer.py b/qubership_pipelines_common_library/v2/artifacts_finder/comparers/simple_version_comparer.py new file mode 100644 index 0000000..41d8ab5 --- /dev/null +++ b/qubership_pipelines_common_library/v2/artifacts_finder/comparers/simple_version_comparer.py @@ -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 diff --git a/qubership_pipelines_common_library/v2/artifacts_finder/model/comparer.py b/qubership_pipelines_common_library/v2/artifacts_finder/model/comparer.py new file mode 100644 index 0000000..c9b3855 --- /dev/null +++ b/qubership_pipelines_common_library/v2/artifacts_finder/model/comparer.py @@ -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: + ... diff --git a/qubership_pipelines_common_library/v2/artifacts_finder/providers/artifactory.py b/qubership_pipelines_common_library/v2/artifacts_finder/providers/artifactory.py index e1cd44d..7325428 100644 --- a/qubership_pipelines_common_library/v2/artifacts_finder/providers/artifactory.py +++ b/qubership_pipelines_common_library/v2/artifacts_finder/providers/artifactory.py @@ -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: @@ -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, @@ -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" @@ -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 diff --git a/qubership_pipelines_common_library/v2/artifacts_finder/providers/aws_code_artifact.py b/qubership_pipelines_common_library/v2/artifacts_finder/providers/aws_code_artifact.py index 5d43c94..f239dd8 100644 --- a/qubership_pipelines_common_library/v2/artifacts_finder/providers/aws_code_artifact.py +++ b/qubership_pipelines_common_library/v2/artifacts_finder/providers/aws_code_artifact.py @@ -16,7 +16,7 @@ def __init__(self, credentials: Credentials, domain: str, repository: str, packa Initializes this client to work with **AWS Code Artifact** for generic or maven artifacts. Requires `Credentials` provided by `AwsCredentialsProvider`. - This provider supports resolving `-SNAPSHOT` artifacts into latest version + This provider supports resolving `-SNAPSHOT` artifacts into latest version and searching for versions with asterisk-wildcards. """ super().__init__(**kwargs) self._credentials = credentials @@ -43,50 +43,84 @@ def download_artifact(self, resource_url: str, local_path: str | Path, **kwargs) with open(local_path, 'wb') as file: file.write(response.get('asset').read()) - def search_artifacts(self, artifact: Artifact, **kwargs) -> list[str]: - if artifact.group_id: - namespaces = [artifact.group_id] + def search_artifacts(self, artifact: Artifact, latest: bool = False, comparer=None, **kwargs) -> list[str]: + namespaces = self._resolve_namespaces(artifact) + if not namespaces: + return [] + + if artifact.has_version_wildcard(): + candidates = self._search_wildcard_versions(artifact, namespaces, latest=latest, comparer=comparer) else: - list_packages_response = self._aws_client.list_packages( - domain=self._domain, repository=self._repository, - format=self._format, packagePrefix=artifact.artifact_id - ) - logging.debug(f"list_packages_response: {list_packages_response}") - namespaces = [package.get('namespace') for package in list_packages_response.get('packages') - if package.get('package') == artifact.artifact_id] - logging.debug(f"namespaces: {namespaces}") - if not namespaces: - logging.warning(f"Found no packages with artifactId = {artifact.artifact_id}!") - return [] - if len(namespaces) > 1: - logging.warning(f"Found multiple namespaces with same artifactId = {artifact.artifact_id}:\n{namespaces}") + candidates = [] + for namespace in namespaces: + if artifact.is_snapshot(): + resolved = self._resolve_snapshot_version(artifact, namespace) + if not resolved: + continue + logging.debug(f"Resolved SNAPSHOT version '{artifact.version}' -> '{resolved}' (namespace: {namespace})") + candidates.append((namespace, resolved)) + else: + candidates.append((namespace, artifact.version)) results = [] - for namespace in namespaces: - package_version = artifact.version - if artifact.is_snapshot(): - resolved = self._resolve_snapshot_version(artifact, namespace) - if not resolved: - continue - package_version = resolved - logging.debug(f"Resolved SNAPSHOT version '{artifact.version}' -> '{package_version}' (namespace: {namespace})") - try: - assets_response = self._aws_client.list_package_version_assets( - domain=self._domain, repository=self._repository, - format=self._format, package=artifact.artifact_id, - packageVersion=package_version, namespace=namespace - ) - for asset in assets_response.get('assets'): - if asset.get('name').lower().endswith(artifact.extension.lower()): - results.append(f"{assets_response.get('namespace')}/{assets_response.get('package')}/" - f"{assets_response.get('version')}/{asset.get('name')}") - except Exception: - logging.warning(f"Specific version ({package_version}) of package ({namespace}.{artifact.artifact_id}) not found!") + for namespace, package_version in candidates: + results.extend(self._collect_assets(artifact, namespace, package_version)) logging.info(f"AWS search results: {results}") return results - def _resolve_snapshot_version(self, artifact: Artifact, namespace: str) -> str | None: - candidate_versions = [] + def get_provider_name(self) -> str: + return "aws_code_artifact" + + def _resolve_namespaces(self, artifact: Artifact) -> list[str]: + if artifact.group_id: + return [artifact.group_id] + list_packages_response = self._aws_client.list_packages( + domain=self._domain, repository=self._repository, + format=self._format, packagePrefix=artifact.artifact_id + ) + logging.debug(f"list_packages_response: {list_packages_response}") + namespaces = [package.get('namespace') for package in list_packages_response.get('packages') + if package.get('package') == artifact.artifact_id] + logging.debug(f"namespaces: {namespaces}") + if not namespaces: + logging.warning(f"Found no packages with artifactId = {artifact.artifact_id}!") + elif len(namespaces) > 1: + logging.warning(f"Found multiple namespaces with same artifactId = {artifact.artifact_id}:\n{namespaces}") + return namespaces + + def _search_wildcard_versions(self, artifact: Artifact, namespaces: list[str], + latest: bool = False, comparer=None) -> list[tuple[str, str]]: + # no server-side version wildcard support -> filter client-side + version_pattern = ArtifactFinderUtils.wildcard_to_regex(artifact.version) + candidates = [ + (namespace, ver) + for namespace in namespaces + for ver in self._list_all_package_versions(artifact, namespace) + if version_pattern.fullmatch(ver) + ] + if latest: + latest_pair = ArtifactFinderUtils.select_latest([(ver, (namespace, ver)) for namespace, ver in candidates], comparer) + return [latest_pair] if latest_pair else [] + return candidates + + def _collect_assets(self, artifact: Artifact, namespace: str, package_version: str) -> list[str]: + results = [] + try: + assets_response = self._aws_client.list_package_version_assets( + domain=self._domain, repository=self._repository, + format=self._format, package=artifact.artifact_id, + packageVersion=package_version, namespace=namespace + ) + for asset in assets_response.get('assets'): + if asset.get('name').lower().endswith(artifact.extension.lower()): + results.append(f"{assets_response.get('namespace')}/{assets_response.get('package')}/" + f"{assets_response.get('version')}/{asset.get('name')}") + except Exception: + logging.warning(f"Specific version ({package_version}) of package ({namespace}.{artifact.artifact_id}) not found!") + return results + + def _list_all_package_versions(self, artifact: Artifact, namespace: str) -> list[str]: + versions = [] next_token = None while True: kwargs = { @@ -100,15 +134,18 @@ def _resolve_snapshot_version(self, artifact: Artifact, namespace: str) -> str | kwargs['nextToken'] = next_token response = self._aws_client.list_package_versions(**kwargs) - for version_entry in response.get('versions', []): - ver = version_entry.get('version', '') - parsed = ArtifactFinderUtils.parse_snapshot_timestamp_version(ver) - if parsed and f"{parsed[0]}-SNAPSHOT" == artifact.version: - candidate_versions.append((parsed[1], ver)) + versions.extend(entry.get('version', '') for entry in response.get('versions', [])) next_token = response.get('nextToken') if not next_token: - break + return versions + + def _resolve_snapshot_version(self, artifact: Artifact, namespace: str) -> str | None: + candidate_versions = [] + for ver in self._list_all_package_versions(artifact, namespace): + parsed = ArtifactFinderUtils.parse_snapshot_timestamp_version(ver) + if parsed and f"{parsed[0]}-SNAPSHOT" == artifact.version: + candidate_versions.append((parsed[1], ver)) if not candidate_versions: logging.debug(f"No snapshot versions found for {artifact.artifact_id}:{artifact.version} in namespace '{namespace}'") @@ -116,6 +153,3 @@ def _resolve_snapshot_version(self, artifact: Artifact, namespace: str) -> str | candidate_versions.sort(key=lambda x: x[0], reverse=True) return candidate_versions[0][1] - - def get_provider_name(self) -> str: - return "aws_code_artifact" diff --git a/qubership_pipelines_common_library/v2/artifacts_finder/providers/azure_artifacts.py b/qubership_pipelines_common_library/v2/artifacts_finder/providers/azure_artifacts.py index ffa93fc..3803650 100644 --- a/qubership_pipelines_common_library/v2/artifacts_finder/providers/azure_artifacts.py +++ b/qubership_pipelines_common_library/v2/artifacts_finder/providers/azure_artifacts.py @@ -17,6 +17,7 @@ def __init__(self, credentials: Credentials, organization: str, project: str, fe Requires `Credentials` provided by `AzureCredentialsProvider`. This provider supports resolving `-SNAPSHOT` artifacts into latest version (in maven-format feeds) + and searching for versions with asterisk-wildcards. """ super().__init__(**kwargs) self._credentials = credentials @@ -28,11 +29,61 @@ def __init__(self, credentials: Credentials, organization: str, project: str, fe 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, **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, comparer=comparer) + acceptable_versions = [artifact.version] if timestamp_version_match := re.match(self.TIMESTAMP_VERSION_PATTERN, artifact.version): acceptable_versions.append(timestamp_version_match.group(1) + "SNAPSHOT") + result_urls = [] + for feed_pkg in self._search_packages(artifact): + pkg_links = feed_pkg.get("_links", {}) + # Filter by acceptable versions (stores snapshot versions literally: "5.0.0-SNAPSHOT") + feed_version = [ + f for f in self._list_package_versions(pkg_links) + if f.get("protocolMetadata", {}).get("data", {}).get("version") in acceptable_versions + ] + if not feed_version: + continue + filtered_feed_version = feed_version[0] + + if artifact.is_snapshot(): + target_file = self._resolve_snapshot_file(filtered_feed_version, artifact) + else: + target_file = self._select_release_file(filtered_feed_version, artifact) + if not target_file: + continue + + result_urls.append(self._build_download_url(pkg_links, filtered_feed_version, target_file.get("name"))) + + return result_urls + + def get_provider_name(self) -> str: + return "azure_artifacts" + + def _search_wildcard_versions(self, artifact: Artifact, latest: bool = False, comparer=None) -> list[str]: + # no server-side version wildcard support -> filter client-side + version_pattern = ArtifactFinderUtils.wildcard_to_regex(artifact.version) + candidates = [] + for feed_pkg in self._search_packages(artifact): + pkg_links = feed_pkg.get("_links", {}) + for version_entry in self._list_package_versions(pkg_links): + version = version_entry.get("protocolMetadata", {}).get("data", {}).get("version", "") + if not version_pattern.fullmatch(version): + continue + target_file = self._select_release_file(version_entry, artifact) + if not target_file: + continue + candidates.append((version, self._build_download_url(pkg_links, version_entry, target_file.get("name")))) + + if latest: + latest_url = ArtifactFinderUtils.select_latest(candidates, comparer) + return [latest_url] if latest_url else [] + return [url for _, url in candidates] + + def _search_packages(self, artifact: Artifact) -> list[dict]: # Search all packages with matching artifact_id feeds_search_url = f"https://feeds.dev.azure.com/{self.organization}/{self.project}/_apis/packaging/feeds/{self.feed}/packages" name_query = f"{artifact.group_id}:{artifact.artifact_id}" if artifact.group_id else artifact.artifact_id @@ -52,78 +103,56 @@ def search_artifacts(self, artifact: Artifact, **kwargs) -> list[str]: packages = feeds_response_json.get("value", []) if not packages: logging.warning("No packages were found.") - return [] - if len(packages) > 1: + elif len(packages) > 1: logging.debug(f"Found multiple packages (groups) for '{artifact.artifact_id}', processing all") + return packages - result_urls = [] - for feed_pkg in packages: - pkg_links = feed_pkg.get("_links", {}) - pkg_versions_url = pkg_links.get("versions", {}).get("href", "") - if not pkg_versions_url: - continue - - pkg_versions_response = self._session.get(url=pkg_versions_url, params={"isDeleted": "false"}, timeout=self.timeout) - if pkg_versions_response.status_code != 200: - logging.warning(f"Skipping package, versions request returned {pkg_versions_response.status_code}") - continue - - feed_versions = pkg_versions_response.json().get("value", []) - if not feed_versions: - continue - - # Filter by acceptable versions (stores snapshot versions literally: "5.0.0-SNAPSHOT") - feed_version = [ - f for f in feed_versions - if f.get("protocolMetadata", {}).get("data", {}).get("version") in acceptable_versions - ] - if not feed_version: + def _list_package_versions(self, pkg_links: dict) -> list[dict]: + pkg_versions_url = pkg_links.get("versions", {}).get("href", "") + if not pkg_versions_url: + return [] + response = self._session.get(url=pkg_versions_url, params={"isDeleted": "false"}, timeout=self.timeout) + if response.status_code != 200: + logging.warning(f"Skipping package, versions request returned {response.status_code}") + return [] + return response.json().get("value", []) + + @staticmethod + def _select_release_file(version_entry: dict, artifact: Artifact) -> dict | None: + for f in version_entry.get("files") or []: + name = f.get("name", "") + if name.startswith(f"{artifact.artifact_id}-") and name.endswith(f".{artifact.extension}"): + return f + return None + + @staticmethod + def _resolve_snapshot_file(version_entry: dict, artifact: Artifact) -> dict | None: + base_version = artifact.version.removesuffix("-SNAPSHOT") + candidate_files = [] + for f in version_entry.get("files") or []: + name = f.get("name", "") + if not name.startswith(f"{artifact.artifact_id}-") or not name.endswith(f".{artifact.extension}"): continue - filtered_feed_version = feed_version[0] - feed_id = pkg_links.get("feed").get("href").split("/")[-1] - feed_version = filtered_feed_version.get("version") - group_id = filtered_feed_version.get("protocolMetadata", {}).get("data", {}).get("groupId") - artifact_id = filtered_feed_version.get("protocolMetadata", {}).get("data", {}).get("artifactId") - - all_version_files = filtered_feed_version.get("files") or [] - if artifact.is_snapshot(): - base_version = artifact.version.removesuffix("-SNAPSHOT") - candidate_files = [] - for f in all_version_files: - name = f.get("name", "") - if not name.startswith(f"{artifact.artifact_id}-") or not name.endswith(f".{artifact.extension}"): - continue - version_part = name.removeprefix(f"{artifact.artifact_id}-").removesuffix(f".{artifact.extension}") - parsed = ArtifactFinderUtils.parse_snapshot_timestamp_version(version_part) - if parsed and parsed[0] == base_version: - candidate_files.append((parsed[1], parsed[2], f)) - if not candidate_files: - logging.warning("No snapshot files found.") - continue - candidate_files.sort(key=lambda x: (x[0], x[1]), reverse=True) - target_file = candidate_files[0][2] - logging.debug(f"Resolved SNAPSHOT version '{artifact.version}' -> '{target_file.get('name')}' (group_id: {group_id})") - else: - target_file = None - for f in all_version_files: - name = f.get("name", "") - if name.startswith(f"{artifact.artifact_id}-") and name.endswith(f".{artifact.extension}"): - target_file = f - break - if not target_file: - continue - - # Build download url - target_file_name = target_file.get("name") - - download_url = ( - f"https://pkgs.dev.azure.com/{self.organization}/{self.project}/_apis/packaging/feeds/{feed_id}/maven/" - f"{group_id}/{artifact_id}/{feed_version}/{target_file_name}/content" - f"?api-version=7.1-preview.1" - ) - result_urls.append(download_url) - - return result_urls - - def get_provider_name(self) -> str: - return "azure_artifacts" + version_part = name.removeprefix(f"{artifact.artifact_id}-").removesuffix(f".{artifact.extension}") + parsed = ArtifactFinderUtils.parse_snapshot_timestamp_version(version_part) + if parsed and parsed[0] == base_version: + candidate_files.append((parsed[1], parsed[2], f)) + if not candidate_files: + logging.warning("No snapshot files found.") + return None + candidate_files.sort(key=lambda x: (x[0], x[1]), reverse=True) + target_file = candidate_files[0][2] + logging.debug(f"Resolved SNAPSHOT version '{artifact.version}' -> '{target_file.get('name')}'") + return target_file + + def _build_download_url(self, pkg_links: dict, version_entry: dict, file_name: str) -> str: + feed_id = pkg_links.get("feed").get("href").split("/")[-1] + data = version_entry.get("protocolMetadata", {}).get("data", {}) + group_id = data.get("groupId") + artifact_id = data.get("artifactId") + feed_version = version_entry.get("version") + return ( + f"https://pkgs.dev.azure.com/{self.organization}/{self.project}/_apis/packaging/feeds/{feed_id}/maven/" + f"{group_id}/{artifact_id}/{feed_version}/{file_name}/content" + f"?api-version=7.1-preview.1" + ) diff --git a/qubership_pipelines_common_library/v2/artifacts_finder/providers/gcp_artifact_registry.py b/qubership_pipelines_common_library/v2/artifacts_finder/providers/gcp_artifact_registry.py index fe9018d..275336c 100644 --- a/qubership_pipelines_common_library/v2/artifacts_finder/providers/gcp_artifact_registry.py +++ b/qubership_pipelines_common_library/v2/artifacts_finder/providers/gcp_artifact_registry.py @@ -20,6 +20,7 @@ def __init__(self, credentials: Credentials, project: str, region_name: str, rep Requires `Credentials` provided by `GcpCredentialsProvider`. This provider supports resolving `-SNAPSHOT` artifacts into latest version (in maven-format repositories) + and searching for versions with asterisk-wildcards. """ super().__init__(**kwargs) self._credentials = credentials @@ -39,28 +40,64 @@ def download_artifact(self, resource_url: str, local_path: str | Path, **kwargs) with open(local_path, 'wb') as file: file.write(response.content) - def search_artifacts(self, artifact: Artifact, **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, comparer=comparer) if artifact.is_snapshot(): return self._search_snapshot_artifacts(artifact) name_filter = f"{self._repo_resource_id}/files/*{artifact.artifact_id}-{artifact.version}.{artifact.extension}" - list_files_request = artifactregistry_v1.ListFilesRequest( - parent=f"{self._repo_resource_id}", - filter=f'name="{name_filter}"', - ) - files = self._gcp_client.list_files(request=list_files_request) + files = self._list_files(name_filter) - group_filter = None - if artifact.group_id: - group_filter = f"/{artifact.group_id.replace('.', '/')}/" + group_filter = self._group_filter(artifact) urls = [] for file in files: if group_filter and group_filter not in unquote(file.name): continue - download_url = f"{self.GAR_URL_PREFIX}{file.name}{self.GAR_URL_SUFFIX}" - urls.append(download_url) + urls.append(f"{self.GAR_URL_PREFIX}{file.name}{self.GAR_URL_SUFFIX}") return urls + def get_provider_name(self) -> str: + return "gcp_artifact_registry" + + def _search_wildcard_versions(self, artifact: Artifact, latest: bool = False, comparer=None) -> list[str]: + literal = f"{artifact.artifact_id}-{artifact.version.split('*', 1)[0]}" + files = self._list_files(f"{self._repo_resource_id}/files/*{literal}*") + + version_pattern = ArtifactFinderUtils.wildcard_to_regex(artifact.version) + group_filter = self._group_filter(artifact) + name_prefix, name_suffix = f"{artifact.artifact_id}-", f".{artifact.extension}" + candidates = [] # (version_string, download_url) pairs + for file in files: + decoded = unquote(file.name) + if group_filter and group_filter not in decoded: + continue + filename = decoded.rsplit("/", 1)[-1].rsplit(":", 1)[-1] + if not (filename.startswith(name_prefix) and filename.endswith(name_suffix)): + continue + version = filename[len(name_prefix):-len(name_suffix)] + if not version_pattern.fullmatch(version): + continue + candidates.append((version, f"{self.GAR_URL_PREFIX}{file.name}{self.GAR_URL_SUFFIX}")) + + if latest: + latest_url = ArtifactFinderUtils.select_latest(candidates, comparer) + return [latest_url] if latest_url else [] + return [url for _, url in candidates] + + def _list_files(self, name_filter: str): + list_files_request = artifactregistry_v1.ListFilesRequest( + parent=self._repo_resource_id, + filter=f'name="{name_filter}"', + ) + return self._gcp_client.list_files(request=list_files_request) + + @staticmethod + def _group_filter(artifact: Artifact) -> str | None: + if artifact.group_id: + return f"/{artifact.group_id.replace('.', '/')}/" + return None + def _search_snapshot_artifacts(self, artifact: Artifact) -> list[str]: prefix = "*" if artifact.group_id: @@ -95,6 +132,3 @@ def _search_snapshot_artifacts(self, artifact: Artifact) -> list[str]: result_urls.append(url) return result_urls - - def get_provider_name(self) -> str: - return "gcp_artifact_registry" diff --git a/qubership_pipelines_common_library/v2/artifacts_finder/providers/nexus.py b/qubership_pipelines_common_library/v2/artifacts_finder/providers/nexus.py index d9c1ed3..d715ba8 100644 --- a/qubership_pipelines_common_library/v2/artifacts_finder/providers/nexus.py +++ b/qubership_pipelines_common_library/v2/artifacts_finder/providers/nexus.py @@ -6,6 +6,8 @@ class NexusProvider(ArtifactProvider): + SEARCH_ASSETS_PATH = "/service/rest/v1/search/assets" + def __init__(self, registry_url: str, username: str = None, password: str = None, **kwargs): """ Initializes this client to work with **Sonatype Nexus Repository** for maven artifacts. @@ -13,7 +15,7 @@ def __init__(self, registry_url: str, username: str = None, password: str = None Nexus Artifact IDs are case-sensitive (`test-cli` and `test-CLI` are different artifacts) - This provider supports resolving `-SNAPSHOT` artifacts into latest version + This provider supports resolving `-SNAPSHOT` artifacts into latest version and searching for versions with asterisk-wildcards. """ super().__init__(**kwargs) self.registry_url = registry_url @@ -24,24 +26,18 @@ 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, **kwargs) -> list[str]: - search_params = { - "maven.extension": artifact.extension, - "maven.artifactId": artifact.artifact_id, - **({"maven.groupId": artifact.group_id} if artifact.group_id else {}), - } + 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, comparer=comparer) + + search_params = self._base_search_params(artifact) if artifact.is_snapshot(): search_params["maven.baseVersion"] = artifact.version else: search_params["version"] = artifact.version - response = self._session.get(url=f"{self.registry_url}/service/rest/v1/search/assets", - params=search_params, - timeout=self.timeout) - if response.status_code != 200: - raise Exception(f"Could not find '{artifact.artifact_id}' - search request returned {response.status_code}!") - - download_urls = [item.get("downloadUrl") for item in response.json().get("items", [])] + items = self._search_all_assets(search_params, artifact.artifact_id) + download_urls = [item.get("downloadUrl") for item in items] if artifact.is_snapshot(): return ArtifactFinderUtils.resolve_snapshot_versions(artifact=artifact, download_urls=download_urls, provider=self) else: @@ -49,3 +45,46 @@ def search_artifacts(self, artifact: Artifact, **kwargs) -> list[str]: def get_provider_name(self) -> str: return "nexus" + + def _search_wildcard_versions(self, artifact: Artifact, latest: bool = False, comparer=None) -> list[str]: + search_params = self._base_search_params(artifact) + # Nexus only allows trailing wildcards server-side + prefix = artifact.version.split("*", 1)[0] + if prefix: + search_params["version"] = f"{prefix}*" + + version_pattern = ArtifactFinderUtils.wildcard_to_regex(artifact.version) + # pattern guard is matched against the version; latest is selected by filename; + download_urls = [ + item["downloadUrl"] + for item in self._search_all_assets(search_params, artifact.artifact_id) + if version_pattern.fullmatch(item.get("maven2", {}).get("version", "")) + ] + if latest: + 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 _search_all_assets(self, search_params: dict, artifact_id: str) -> list[dict]: + items = [] + continuation_token = None + while True: + page_params = {**search_params, **({"continuationToken": continuation_token} if continuation_token else {})} + response = self._session.get(url=f"{self.registry_url}{self.SEARCH_ASSETS_PATH}", + params=page_params, + timeout=self.timeout) + if response.status_code != 200: + raise Exception(f"Could not find '{artifact_id}' - search request returned {response.status_code}!") + data = response.json() + items.extend(data.get("items", [])) + continuation_token = data.get("continuationToken") + if not continuation_token: + return items + + @staticmethod + def _base_search_params(artifact: Artifact) -> dict: + return { + "maven.extension": artifact.extension, + "maven.artifactId": artifact.artifact_id, + **({"maven.groupId": artifact.group_id} if artifact.group_id else {}), + } diff --git a/qubership_pipelines_common_library/v2/artifacts_finder/utils/artifact_finder_utils.py b/qubership_pipelines_common_library/v2/artifacts_finder/utils/artifact_finder_utils.py index 3f79d79..579c6ef 100644 --- a/qubership_pipelines_common_library/v2/artifacts_finder/utils/artifact_finder_utils.py +++ b/qubership_pipelines_common_library/v2/artifacts_finder/utils/artifact_finder_utils.py @@ -238,10 +238,16 @@ def extract_metadata_snapshot_timestamp(metadata_content: str) -> str: return f"{timestamp}-{build_number}" @staticmethod - def version_sort_key(version: str): + def wildcard_to_regex(pattern: str): import re - 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 + return re.compile(".*".join(re.escape(part) for part in pattern.split("*"))) + + @staticmethod + def select_latest(candidates: list[tuple], comparer=None): + # candidates: (comparable_version_string, payload) pairs. Returns the payload whose version compares greatest + import functools + if not candidates: + return None + if comparer is None: + raise Exception("comparer cannot be None!") + return max(candidates, key=functools.cmp_to_key(lambda a, b: comparer.compare(a[0], b[0])))[1] diff --git a/tests/v2/artifacts_finder/test_artifacts_finder.py b/tests/v2/artifacts_finder/test_artifacts_finder.py index 1036f51..86ac51a 100644 --- a/tests/v2/artifacts_finder/test_artifacts_finder.py +++ b/tests/v2/artifacts_finder/test_artifacts_finder.py @@ -5,10 +5,15 @@ from qubership_pipelines_common_library.v2.artifacts_finder.artifact_finder import ArtifactFinder from qubership_pipelines_common_library.v2.artifacts_finder.auth.aws_credentials import AwsCredentialsProvider +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.credentials import Credentials from qubership_pipelines_common_library.v2.artifacts_finder.providers.artifactory import ArtifactoryProvider +from qubership_pipelines_common_library.v2.artifacts_finder.providers.azure_artifacts import AzureArtifactsProvider +from qubership_pipelines_common_library.v2.artifacts_finder.providers.gcp_artifact_registry import GcpArtifactRegistryProvider from qubership_pipelines_common_library.v2.artifacts_finder.providers.nexus import NexusProvider +from qubership_pipelines_common_library.v2.artifacts_finder.utils.artifact_finder_utils import ArtifactFinderUtils class TestArtifactFinder: @@ -74,6 +79,12 @@ def test_credentials_provider_contract(self, boto_client_mock): assert credentials.access_key == "test_assumed_access_key" assert credentials.secret_key == "test_assumed_secret_key" + def test_select_latest_returns_payload_of_greatest_comparable(self): + comparer = DefaultVersionComparer() + candidates = [("1.0.0", "url-a"), ("2.0.0", "url-b"), ("1.5.0", "url-c")] + assert ArtifactFinderUtils.select_latest(candidates, comparer) == "url-b" + assert ArtifactFinderUtils.select_latest([], comparer) is None + @patch('requests.sessions.Session.get') def test_nexus_search_snapshot_resolution(self, requests_mock): def side_effect(url, **kwargs): @@ -133,3 +144,122 @@ def side_effect(url, **kwargs): latest = finder.find_artifact_urls(artifact_id="test-component", version="master-*-RELEASE", extension="yaml", latest=True) assert latest == [f"{base}/master-6.0.0-20260301.140000-RELEASE/test-component-master-6.0.0-20260301.140000-RELEASE.yaml"] + + @patch('requests.sessions.Session.get') + def test_nexus_version_wildcard_search_paginated(self, requests_mock): + base = "https://mock.nexus.url/repository/test-mvn/com/sometest/group2/light-config" + + def asset(version, ext="yaml"): + return { + "downloadUrl": f"{base}/{version}/light-config-{version}.{ext}", + "maven2": {"artifactId": "light-config", "extension": ext, "version": version}, + } + + page1 = [asset("master-5.0.0-RELEASE"), asset("master-5.0.1-RELEASE")] + page2 = [asset("master-6.0.0-RELEASE"), asset("master-6.0.0-DEV")] + + def side_effect(url, **kwargs): + params = kwargs.get("params", {}) + mock_resp = Mock() + mock_resp.status_code = 200 + if params.get("continuationToken") == "TOKEN": + mock_resp.json.return_value = {"items": page2, "continuationToken": None} + else: + mock_resp.json.return_value = {"items": page1, "continuationToken": "TOKEN"} + return mock_resp + + requests_mock.side_effect = side_effect + finder = ArtifactFinder(artifact_provider=NexusProvider(registry_url="https://mock.nexus.url")) + + urls = finder.find_artifact_urls(artifact_id="light-config", version="master-*-RELEASE", extension="yaml") + assert len(urls) == 3 + + latest = finder.find_artifact_urls(artifact_id="light-config", version="master-*-RELEASE", + extension="yaml", latest=True) + assert latest == [f"{base}/master-6.0.0-RELEASE/light-config-master-6.0.0-RELEASE.yaml"] + + @patch('requests.sessions.Session.get') + def test_azure_version_wildcard_search(self, requests_mock): + org, project, feed_id = "myorg", "myproj", "MYFEEDID" + versions_url = f"https://feeds.dev.azure.com/{org}/{project}/_apis/packaging/feeds/{feed_id}/packages/PKGID/versions" + + def version_entry(v): + return { + "version": v, + "protocolMetadata": {"data": {"version": v, "groupId": "com.example", "artifactId": "test-component"}}, + "files": [{"name": f"test-component-{v}.yaml"}, {"name": "test-component.pom"}], + } + + all_versions = [ + version_entry("master-5.0.0-RELEASE"), version_entry("master-5.0.1-RELEASE"), + version_entry("master-6.0.0-RELEASE"), version_entry("dev-1.0.0-RELEASE"), + ] + + def side_effect(url, **kwargs): + mock_resp = Mock() + mock_resp.status_code = 200 + if url == versions_url: + mock_resp.json.return_value = {"value": all_versions} + else: + mock_resp.json.return_value = {"value": [{"_links": { + "versions": {"href": versions_url}, + "feed": {"href": f"https://feeds.dev.azure.com/{org}/{project}/_apis/packaging/feeds/{feed_id}"}, + }}]} + return mock_resp + + requests_mock.side_effect = side_effect + finder = ArtifactFinder(artifact_provider=AzureArtifactsProvider( + credentials=Credentials(access_token="token"), organization=org, project=project, feed="myfeed")) + + def expected_url(v): + return (f"https://pkgs.dev.azure.com/{org}/{project}/_apis/packaging/feeds/{feed_id}/maven/" + f"com.example/test-component/{v}/test-component-{v}.yaml/content?api-version=7.1-preview.1") + + urls = finder.find_artifact_urls(artifact_id="test-component", version="master-*-RELEASE", extension="yaml") + assert urls == [ + expected_url("master-5.0.0-RELEASE"), + expected_url("master-5.0.1-RELEASE"), + expected_url("master-6.0.0-RELEASE"), + ] + + latest = finder.find_artifact_urls(artifact_id="test-component", version="master-*-RELEASE", + extension="yaml", latest=True) + assert latest == [expected_url("master-6.0.0-RELEASE")] + + @patch('google.cloud.artifactregistry_v1.ArtifactRegistryClient') + def test_gcp_version_wildcard_search(self, gcp_client_cls): + repo = "projects/proj/locations/us/repositories/repo" + + def gcp_file(version): + encoded = f"com%2Fexample%2Ftest-component%2F{version}%2Ftest-component-{version}.yaml" + from types import SimpleNamespace + return SimpleNamespace(name=f"{repo}/files/{encoded}") + + gcp_client = Mock() + gcp_client_cls.return_value = gcp_client + gcp_client.list_files.return_value = [ + gcp_file("master-5.0.0-RELEASE"), gcp_file("master-5.0.1-RELEASE"), + gcp_file("master-6.0.0-RELEASE"), gcp_file("dev-1.0.0-RELEASE"), + ] + + finder = ArtifactFinder(artifact_provider=GcpArtifactRegistryProvider( + credentials=Credentials(google_credentials_object=Mock(), authorized_session=Mock()), + project="proj", region_name="us", repository="repo")) + + def expected_url(version): + encoded = f"com%2Fexample%2Ftest-component%2F{version}%2Ftest-component-{version}.yaml" + return f"https://artifactregistry.googleapis.com/download/v1/{repo}/files/{encoded}:download?alt=media" + + urls = finder.find_artifact_urls(artifact_id="test-component", version="master-*-RELEASE", extension="yaml") + assert urls == [ + expected_url("master-5.0.0-RELEASE"), + expected_url("master-5.0.1-RELEASE"), + expected_url("master-6.0.0-RELEASE"), + ] + + sent_filter = gcp_client.list_files.call_args.kwargs["request"].filter + assert sent_filter == f'name="{repo}/files/*test-component-master-*"' + + latest = finder.find_artifact_urls(artifact_id="test-component", version="master-*-RELEASE", + extension="yaml", latest=True) + assert latest == [expected_url("master-6.0.0-RELEASE")] diff --git a/tests/v2/artifacts_finder/test_version_comparers.py b/tests/v2/artifacts_finder/test_version_comparers.py new file mode 100644 index 0000000..dc83709 --- /dev/null +++ b/tests/v2/artifacts_finder/test_version_comparers.py @@ -0,0 +1,66 @@ +from qubership_pipelines_common_library.v2.artifacts_finder.comparers.default_version_comparer import DefaultVersionComparer + +# (v1, v2, description, expected) +# expected: -2 major upgrade, -1 minor/patch upgrade or lex smaller, +# 0 equal, +1 minor/patch downgrade or lex greater, +2 major downgrade +VERSION_COMPARE_CASES = [ + # --- SEMVER vs SEMVER ------------------------------------------------- + ("1.0.0", "2.0.0", "major upgrade", -2), + ("2.0.0", "1.9.9", "major downgrade", 2), + ("1.2.3", "1.2.3", "identical versions", 0), + ("1.2.3", "1.2.4", "patch increment", -1), + ("1.3.0", "1.2.99", "minor beats large patch", 1), + ("0.0.1", "0.0.2", "tiny patch diff", -1), + ("10.0.0", "9.99.99", "major downgrade (numeric, not lex)", 2), + + ("v26.2_main_r1.1.0-20260525.103047-1-RELEASE", "v26.2_main_r1.1.0-20260525.103047-1-RELEASE", "same build, identical semver", 0), + ("v26.2_main_r1.1.0-20260525.103047-1-RELEASE", "v26.2_main_r1.2.0-20270525.103047-1-RELEASE", "same prefix, minor increment", -1), + ("v26.2_main_r1.2.0-20260525.103047-1-RELEASE", "v26.3_main_r1.1.0-20270525.103047-1-RELEASE", "release increment", -1), + ("release-1.0.0-20231116.090331-1-RELEASE", "release-1.1.0-20241116.090331-1-RELEASE", "release branch, minor increment", -1), + ("v1.2.0-20260525.052827-13885121-RELEASE", "v1.10.0-20260625.052827-13885121-RELEASE", "minor 2 vs 10, numeric wins over lex", -1), + ("v1.0.5-20260505.110551-13623799-RELEASE", "v10.0.5-20260605.110551-13623799-RELEASE", "major upgrade 1 -> 10", -2), + ("master-20260512.013338-63-RELEASE", "master-20260612.013338-64-RELEASE", "no semver, lexico by timestamp", -1), + ("release-2026-1-1_20260209-012147-20260209.012800-1-RELEASE", "release-2026-2-1_20260209-012147-20260209.012800-2-RELEASE", "no semver, lexico by date segment", -1), + ("v2.10.4-cloud_dd-20260601.080053-1-RELEASE", "v2.106.4-cloud_dd-20260601.080053-2-RELEASE", "minor 10 vs 106, same major", -1), + ("v2.106.3-cloud_dd-20260601.080053-1-RELEASE", "v2.107.1-cloud_dd-20260601.080053-1-RELEASE", "minor 106 vs 107, same major", -1), + ("v2.106.3-cloud_dd-20260601.080053-1-RELEASE", "v3.1.1-cloud_dd-20250601.080053-1-RELEASE", "major upgrade 2 -> 3 (cloud_dd)", -2), + ("v2.5.1-20260513.075133-1-RELEASE", "v3.4.1-20260513.075133-2-RELEASE", "major upgrade 2 -> 3", -2), + ("v2.81.5-20260519.042017-6-RELEASE", "v2.82.5-20260519.042017-6-RELEASE", "minor increment, same major", -1), + ("v0.41.3-20260529.075449-1-RELEASE", "v1.4.3-20260530.075449-1-RELEASE", "major upgrade 0 -> 1", -2), + ("release-20260508.064312-67-RELEASE", "release-20270508.064312-1-RELEASE", "no semver, lexico by year in timestamp", -1), + ("pg18-1.53.7-20260508.113626-2-RELEASE", "pg18-1.54.6-20260408.113626-1-RELEASE", "pg18, minor increment", -1), + ("pg18-1.53.7-20260508.113626-2-RELEASE", "pg18-2.4.80-20260408.113626-2-RELEASE", "pg18, major upgrade 1 -> 2", -2), + ("pg18-1.53.7-20260508.113626-2-RELEASE", "pg17-2.4.80-20260408.113626-2-RELEASE", "prefix pg18 > pg17, prefix wins before semver", 1), + ("1.53.7-supplementary-20260508.113557-2-RELEASE", "2.1.1-supplementary-20260506.113557-1-RELEASE", "no prefix, major upgrade 1 -> 2", -2), + ("arango3.11-0.47.4-20260503.152239-6-RELEASE", "arango3.11-0.50.2-20260503.152239-5-RELEASE", "arango, minor 47 vs 50, major both 0", -1), + ("clickhouse243-0.45.1-20260520.031714-1-RELEASE", "clickhouse243-0.5.1-20260520.021714-1-RELEASE", "minor 45 vs 5, major both 0: 45 > 5", 1), + ("0.45.1-supplementary-20260520.031302-1-RELEASE", "0.50.1-supplementary-20260520.031302-1-RELEASE", "no prefix, minor 45 vs 50, major both 0", -1), + ("zk3-0.11.17-20260416.070721-4-RELEASE", "zk3-1.0.0-20260416.070721-3-RELEASE", "zk3, major upgrade 0 -> 1", -2), + ("0.7.9-20260417.102956-4-RELEASE", "0.18.5-20260417.102956-2-RELEASE", "no prefix, minor 7 vs 18, major both 0", -1), + ("redis-crd-4.2.4-20260525.064429-1-RELEASE", "redis-crd-4.11.1-20260525.064429-1-RELEASE", "redis-crd, minor 2 vs 11, same major", -1), + ("v3.10.1-charts_argocd-20260504.081015-2-RELEASE", "v3.11.1-charts_argocd-20260504.081015-2-RELEASE", "argocd charts, minor 10 vs 11, same major", -1), + + # --- mixed / non-SEMVER ----------------------------------------------- + ("1.0", "1.0.0", "missing patch -> lexicographic", -1), + ("v1.0.0", "v2.0.0", "semver with v-prefix, major upgrade", -2), + ("alpha", "beta", "plain words", -1), + ("1.0.0-rc.1", "1.0.0", "same semver, suffix makes it greater", 1), + ("abc", "abc", "equal strings", 0), + ("2.0", "10.0", "lex: '2' > '1' even though 2 < 10", 1), + ("release-1.2.3", "release-1.2.4", "prefixed release strings", -1), +] + + +class TestDefaultVersionComparer: + + def test_compare(self): + comparer = DefaultVersionComparer() + failures = [] + for v1, v2, desc, expected in VERSION_COMPARE_CASES: + actual = comparer.compare(v1, v2) + if actual != expected: + failures.append(f" [{desc}] compare({v1!r}, {v2!r}) -> {actual}, expected {expected}") + swapped = comparer.compare(v2, v1) + if swapped != -expected: + failures.append(f" [{desc}] compare({v2!r}, {v1!r}) -> {swapped}, expected {-expected}") + assert not failures, f"{len(failures)} version comparison(s) failed:\n" + "\n".join(failures)