From c39af8ab57b390cdeecb96edebf9f4e369914165 Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Thu, 19 Mar 2026 22:03:37 +0000 Subject: [PATCH 01/31] refactor copr repo provider to facilitate listing packages --- tmt/steps/prepare/artifact/providers/copr_repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tmt/steps/prepare/artifact/providers/copr_repository.py b/tmt/steps/prepare/artifact/providers/copr_repository.py index f763205c9e..c08db71c94 100644 --- a/tmt/steps/prepare/artifact/providers/copr_repository.py +++ b/tmt/steps/prepare/artifact/providers/copr_repository.py @@ -79,7 +79,7 @@ def _extract_provider_id(cls, raw_id: str) -> ArtifactProviderId: if not raw_id.startswith(prefix): raise ValueError(f"Invalid Copr repository provider format: '{raw_id}'.") - value = raw_id[len(prefix) :] + value = raw_id[len(prefix):] if not value: raise ValueError("Missing Copr repository name.") From cf01d3b0ae32912370b5294d1d2579e205f11ee9 Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Fri, 13 Mar 2026 13:59:54 +0000 Subject: [PATCH 02/31] refactor copr repo provider to fetch .repo file via api --- tmt/steps/prepare/artifact/providers/_copr.py | 92 +++++++++++++++++++ .../prepare/artifact/providers/copr_build.py | 70 +++----------- 2 files changed, 106 insertions(+), 56 deletions(-) create mode 100644 tmt/steps/prepare/artifact/providers/_copr.py diff --git a/tmt/steps/prepare/artifact/providers/_copr.py b/tmt/steps/prepare/artifact/providers/_copr.py new file mode 100644 index 0000000000..651bc3e206 --- /dev/null +++ b/tmt/steps/prepare/artifact/providers/_copr.py @@ -0,0 +1,92 @@ +""" +Shared COPR utilities for copr-based artifact providers. +""" + +import types +from abc import abstractmethod +from functools import cached_property +from typing import Any, Optional + +import tmt.log +import tmt.utils +import tmt.utils.hints +from tmt.steps.prepare.artifact.providers import ArtifactProvider + +copr: Optional[types.ModuleType] = None + +# To silence mypy +Client: Any + +tmt.utils.hints.register_hint( + 'artifact-provider/copr', + """ +The ``copr`` Python package is required by tmt for Copr integration. + +To quickly test Copr presence, you can try running: + + python -c 'import copr' + +* Users who installed tmt from PyPI should install the ``copr`` package + via ``pip install copr``. +""", +) + + +def import_copr(logger: tmt.log.Logger) -> None: + """Import copr module with error handling.""" + global copr, Client + try: + import copr + from copr.v3 import Client + except ImportError as error: + from tmt.utils.hints import print_hints + + print_hints('artifact-provider/copr', logger=logger) + + raise tmt.utils.GeneralError("Could not import copr package.") from error + + +class CoprArtifactProvider(ArtifactProvider): + """ + Base class for COPR-based artifact providers. + """ + + def __init__(self, raw_id: str, repository_priority: int, logger: tmt.log.Logger) -> None: + super().__init__(raw_id, repository_priority, logger) + self._session = self._initialize_session() + + def _initialize_session(self) -> 'Client': + """ + Initialize copr client session. + """ + import_copr(self.logger) + + try: + config = {"copr_url": "https://copr.fedorainfracloud.org"} + return Client(config) + except Exception as error: + raise tmt.utils.GeneralError("Failed to initialize Copr client session.") from error + + @property + @abstractmethod + def _copr_owner(self) -> str: + raise NotImplementedError + + @property + @abstractmethod + def _copr_project(self) -> str: + raise NotImplementedError + + @cached_property + def project_info(self) -> Any: + """ + Fetch and return the COPR project metadata. + """ + try: + return self._session.project_proxy.get( + ownername=self._copr_owner, projectname=self._copr_project + ) + except Exception as error: + raise tmt.utils.GeneralError( + f"Failed to fetch COPR project info for '{self._copr_owner}/{self._copr_project}'." + ) from error diff --git a/tmt/steps/prepare/artifact/providers/copr_build.py b/tmt/steps/prepare/artifact/providers/copr_build.py index a8e28a095e..8efea05812 100644 --- a/tmt/steps/prepare/artifact/providers/copr_build.py +++ b/tmt/steps/prepare/artifact/providers/copr_build.py @@ -2,66 +2,31 @@ Copr Build Artifact Provider """ -import types from collections.abc import Sequence from functools import cached_property from shlex import quote -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Optional from urllib.parse import urljoin import tmt.log import tmt.utils -import tmt.utils.hints from tmt.guest import Guest from tmt.package_managers._rpm import RpmVersion from tmt.steps.prepare.artifact.providers import ( ArtifactInfo, - ArtifactProvider, ArtifactProviderId, DownloadError, provides_artifact_provider, ) +from tmt.steps.prepare.artifact.providers._copr import CoprArtifactProvider from tmt.utils import ShellScript if TYPE_CHECKING: from munch import Munch -copr: Optional[types.ModuleType] = None - -# To silence mypy -Client: Any - -tmt.utils.hints.register_hint( - 'artifact-provider/copr', - """ -The ``copr`` Python package is required by tmt for Copr integration. - -To quickly test Copr presence, you can try running: - - python -c 'import copr' - -* Users who installed tmt from PyPI should install the ``copr`` package - via ``pip install copr``. -""", -) - - -def import_copr(logger: tmt.log.Logger) -> None: - """Import copr module with error handling.""" - global copr, Client - try: - import copr - from copr.v3 import Client - except ImportError as error: - from tmt.utils.hints import print_hints - - print_hints('artifact-provider/copr', logger=logger) - - raise tmt.utils.GeneralError("Could not import copr package.") from error - @provides_artifact_provider("copr.build") -class CoprBuildArtifactProvider(ArtifactProvider): +class CoprBuildArtifactProvider(CoprArtifactProvider): """ Provider for downloading artifacts from Copr builds. @@ -80,7 +45,6 @@ class CoprBuildArtifactProvider(ArtifactProvider): def __init__(self, raw_id: str, repository_priority: int, logger: tmt.log.Logger): super().__init__(raw_id, repository_priority, logger) - self._session = self._initialize_session() try: build_id_str, chroot = self.id.split(":", 1) self.build_id = int(build_id_str) @@ -97,28 +61,22 @@ def build_info(self) -> Optional["Munch"]: """ return self._session.build_proxy.get(self.build_id) + @property + def _copr_owner(self) -> str: + assert self.build_info is not None + return str(self.build_info.ownername) + + @property + def _copr_project(self) -> str: + assert self.build_info is not None + return str(self.build_info.projectname) + @cached_property def is_pulp(self) -> bool: """ Check if the build is stored in Pulp. """ - assert self.build_info is not None - project = self._session.project_proxy.get( - self.build_info.ownername, self.build_info.projectname - ) - return project is not None and project.storage == "pulp" - - def _initialize_session(self) -> 'Client': - """ - Initialize copr client session. - """ - import_copr(self.logger) - - try: - config = {"copr_url": "https://copr.fedorainfracloud.org"} - return Client(config) - except Exception as error: - raise tmt.utils.GeneralError("Failed to initialize Copr client session.") from error + return self.project_info is not None and self.project_info.storage == "pulp" @classmethod def _extract_provider_id(cls, raw_id: str) -> ArtifactProviderId: From 9412400a87e1e1dca91604dc0124306555bc9d87 Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Mon, 16 Mar 2026 10:51:01 +0000 Subject: [PATCH 03/31] refactor: move COPR utilities to the sharred module --- tmt/steps/prepare/artifact/providers/_copr.py | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/tmt/steps/prepare/artifact/providers/_copr.py b/tmt/steps/prepare/artifact/providers/_copr.py index 651bc3e206..e807fea12d 100644 --- a/tmt/steps/prepare/artifact/providers/_copr.py +++ b/tmt/steps/prepare/artifact/providers/_copr.py @@ -1,7 +1,8 @@ """ -Shared COPR utilities for copr-based artifact providers. +Shared COPR utilities. """ +import re import types from abc import abstractmethod from functools import cached_property @@ -32,6 +33,33 @@ ) +COPR_URL = 'https://copr.fedorainfracloud.org/coprs' +COPR_REPO_PATTERN = re.compile(r'^(@)?([^/]+)/([^/]+)$') + + +def parse_copr_repo(copr_repo: str) -> tuple[bool, str, str]: + """ + Parse a COPR repository identifier into its components. + """ + matched = COPR_REPO_PATTERN.match(copr_repo) + if not matched: + raise tmt.utils.PrepareError(f"Invalid copr repository '{copr_repo}'.") + is_group, name, project = matched.groups() + return bool(is_group), name, project + + +def build_copr_repo_url(copr_repo: str, chroot: str) -> str: + """ + Construct the URL for a COPR ``.repo`` file. + """ + is_group, name, project = parse_copr_repo(copr_repo) + group = 'group_' if is_group else '' + parts = [COPR_URL] + (['g'] if is_group else []) + parts += [name, project, 'repo', chroot] + parts += [f"{group}{name}-{project}-{chroot}.repo"] + return '/'.join(parts) + + def import_copr(logger: tmt.log.Logger) -> None: """Import copr module with error handling.""" global copr, Client From f2dbad5b1ef5024618fced960e47216c29d3fad1 Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Fri, 20 Mar 2026 11:45:19 +0000 Subject: [PATCH 04/31] rebasing... --- tmt/steps/prepare/artifact/providers/copr_repository.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tmt/steps/prepare/artifact/providers/copr_repository.py b/tmt/steps/prepare/artifact/providers/copr_repository.py index c08db71c94..6cb047bbde 100644 --- a/tmt/steps/prepare/artifact/providers/copr_repository.py +++ b/tmt/steps/prepare/artifact/providers/copr_repository.py @@ -96,6 +96,11 @@ def get_repositories(self) -> list[Repository]: return [] return [self.repository] + def get_repositories(self) -> list[Repository]: + if self.repository is None: + return [] + return [self.repository] + def _download_artifact(self, artifact: ArtifactInfo, guest: Guest, destination: Path) -> None: """This provider only enables repositories; it does not download individual RPMs.""" raise UnsupportedOperationError( From 98a3be1e200c89917a8fc33136919761b895d36d Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Fri, 20 Mar 2026 11:49:22 +0000 Subject: [PATCH 05/31] more rebasing... --- tmt/steps/prepare/artifact/providers/_copr.py | 120 ------------------ .../prepare/artifact/providers/copr_build.py | 70 ++++++++-- 2 files changed, 56 insertions(+), 134 deletions(-) delete mode 100644 tmt/steps/prepare/artifact/providers/_copr.py diff --git a/tmt/steps/prepare/artifact/providers/_copr.py b/tmt/steps/prepare/artifact/providers/_copr.py deleted file mode 100644 index e807fea12d..0000000000 --- a/tmt/steps/prepare/artifact/providers/_copr.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -Shared COPR utilities. -""" - -import re -import types -from abc import abstractmethod -from functools import cached_property -from typing import Any, Optional - -import tmt.log -import tmt.utils -import tmt.utils.hints -from tmt.steps.prepare.artifact.providers import ArtifactProvider - -copr: Optional[types.ModuleType] = None - -# To silence mypy -Client: Any - -tmt.utils.hints.register_hint( - 'artifact-provider/copr', - """ -The ``copr`` Python package is required by tmt for Copr integration. - -To quickly test Copr presence, you can try running: - - python -c 'import copr' - -* Users who installed tmt from PyPI should install the ``copr`` package - via ``pip install copr``. -""", -) - - -COPR_URL = 'https://copr.fedorainfracloud.org/coprs' -COPR_REPO_PATTERN = re.compile(r'^(@)?([^/]+)/([^/]+)$') - - -def parse_copr_repo(copr_repo: str) -> tuple[bool, str, str]: - """ - Parse a COPR repository identifier into its components. - """ - matched = COPR_REPO_PATTERN.match(copr_repo) - if not matched: - raise tmt.utils.PrepareError(f"Invalid copr repository '{copr_repo}'.") - is_group, name, project = matched.groups() - return bool(is_group), name, project - - -def build_copr_repo_url(copr_repo: str, chroot: str) -> str: - """ - Construct the URL for a COPR ``.repo`` file. - """ - is_group, name, project = parse_copr_repo(copr_repo) - group = 'group_' if is_group else '' - parts = [COPR_URL] + (['g'] if is_group else []) - parts += [name, project, 'repo', chroot] - parts += [f"{group}{name}-{project}-{chroot}.repo"] - return '/'.join(parts) - - -def import_copr(logger: tmt.log.Logger) -> None: - """Import copr module with error handling.""" - global copr, Client - try: - import copr - from copr.v3 import Client - except ImportError as error: - from tmt.utils.hints import print_hints - - print_hints('artifact-provider/copr', logger=logger) - - raise tmt.utils.GeneralError("Could not import copr package.") from error - - -class CoprArtifactProvider(ArtifactProvider): - """ - Base class for COPR-based artifact providers. - """ - - def __init__(self, raw_id: str, repository_priority: int, logger: tmt.log.Logger) -> None: - super().__init__(raw_id, repository_priority, logger) - self._session = self._initialize_session() - - def _initialize_session(self) -> 'Client': - """ - Initialize copr client session. - """ - import_copr(self.logger) - - try: - config = {"copr_url": "https://copr.fedorainfracloud.org"} - return Client(config) - except Exception as error: - raise tmt.utils.GeneralError("Failed to initialize Copr client session.") from error - - @property - @abstractmethod - def _copr_owner(self) -> str: - raise NotImplementedError - - @property - @abstractmethod - def _copr_project(self) -> str: - raise NotImplementedError - - @cached_property - def project_info(self) -> Any: - """ - Fetch and return the COPR project metadata. - """ - try: - return self._session.project_proxy.get( - ownername=self._copr_owner, projectname=self._copr_project - ) - except Exception as error: - raise tmt.utils.GeneralError( - f"Failed to fetch COPR project info for '{self._copr_owner}/{self._copr_project}'." - ) from error diff --git a/tmt/steps/prepare/artifact/providers/copr_build.py b/tmt/steps/prepare/artifact/providers/copr_build.py index 8efea05812..a8e28a095e 100644 --- a/tmt/steps/prepare/artifact/providers/copr_build.py +++ b/tmt/steps/prepare/artifact/providers/copr_build.py @@ -2,31 +2,66 @@ Copr Build Artifact Provider """ +import types from collections.abc import Sequence from functools import cached_property from shlex import quote -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Any, Optional from urllib.parse import urljoin import tmt.log import tmt.utils +import tmt.utils.hints from tmt.guest import Guest from tmt.package_managers._rpm import RpmVersion from tmt.steps.prepare.artifact.providers import ( ArtifactInfo, + ArtifactProvider, ArtifactProviderId, DownloadError, provides_artifact_provider, ) -from tmt.steps.prepare.artifact.providers._copr import CoprArtifactProvider from tmt.utils import ShellScript if TYPE_CHECKING: from munch import Munch +copr: Optional[types.ModuleType] = None + +# To silence mypy +Client: Any + +tmt.utils.hints.register_hint( + 'artifact-provider/copr', + """ +The ``copr`` Python package is required by tmt for Copr integration. + +To quickly test Copr presence, you can try running: + + python -c 'import copr' + +* Users who installed tmt from PyPI should install the ``copr`` package + via ``pip install copr``. +""", +) + + +def import_copr(logger: tmt.log.Logger) -> None: + """Import copr module with error handling.""" + global copr, Client + try: + import copr + from copr.v3 import Client + except ImportError as error: + from tmt.utils.hints import print_hints + + print_hints('artifact-provider/copr', logger=logger) + + raise tmt.utils.GeneralError("Could not import copr package.") from error + @provides_artifact_provider("copr.build") -class CoprBuildArtifactProvider(CoprArtifactProvider): +class CoprBuildArtifactProvider(ArtifactProvider): """ Provider for downloading artifacts from Copr builds. @@ -45,6 +80,7 @@ class CoprBuildArtifactProvider(CoprArtifactProvider): def __init__(self, raw_id: str, repository_priority: int, logger: tmt.log.Logger): super().__init__(raw_id, repository_priority, logger) + self._session = self._initialize_session() try: build_id_str, chroot = self.id.split(":", 1) self.build_id = int(build_id_str) @@ -61,22 +97,28 @@ def build_info(self) -> Optional["Munch"]: """ return self._session.build_proxy.get(self.build_id) - @property - def _copr_owner(self) -> str: - assert self.build_info is not None - return str(self.build_info.ownername) - - @property - def _copr_project(self) -> str: - assert self.build_info is not None - return str(self.build_info.projectname) - @cached_property def is_pulp(self) -> bool: """ Check if the build is stored in Pulp. """ - return self.project_info is not None and self.project_info.storage == "pulp" + assert self.build_info is not None + project = self._session.project_proxy.get( + self.build_info.ownername, self.build_info.projectname + ) + return project is not None and project.storage == "pulp" + + def _initialize_session(self) -> 'Client': + """ + Initialize copr client session. + """ + import_copr(self.logger) + + try: + config = {"copr_url": "https://copr.fedorainfracloud.org"} + return Client(config) + except Exception as error: + raise tmt.utils.GeneralError("Failed to initialize Copr client session.") from error @classmethod def _extract_provider_id(cls, raw_id: str) -> ArtifactProviderId: From e26d1f75141a6c4d8be8731ca307d245b89c44ee Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Fri, 20 Mar 2026 15:00:56 +0000 Subject: [PATCH 06/31] address comments... --- tmt/steps/prepare/artifact/providers/copr_repository.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tmt/steps/prepare/artifact/providers/copr_repository.py b/tmt/steps/prepare/artifact/providers/copr_repository.py index 6cb047bbde..c08db71c94 100644 --- a/tmt/steps/prepare/artifact/providers/copr_repository.py +++ b/tmt/steps/prepare/artifact/providers/copr_repository.py @@ -96,11 +96,6 @@ def get_repositories(self) -> list[Repository]: return [] return [self.repository] - def get_repositories(self) -> list[Repository]: - if self.repository is None: - return [] - return [self.repository] - def _download_artifact(self, artifact: ArtifactInfo, guest: Guest, destination: Path) -> None: """This provider only enables repositories; it does not download individual RPMs.""" raise UnsupportedOperationError( From cbc3ff481ab365fe0b0f50010ee98aa6af6fd65c Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Tue, 24 Mar 2026 12:46:45 +0000 Subject: [PATCH 07/31] address comments... --- tmt/steps/prepare/artifact/providers/copr_repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tmt/steps/prepare/artifact/providers/copr_repository.py b/tmt/steps/prepare/artifact/providers/copr_repository.py index c08db71c94..f763205c9e 100644 --- a/tmt/steps/prepare/artifact/providers/copr_repository.py +++ b/tmt/steps/prepare/artifact/providers/copr_repository.py @@ -79,7 +79,7 @@ def _extract_provider_id(cls, raw_id: str) -> ArtifactProviderId: if not raw_id.startswith(prefix): raise ValueError(f"Invalid Copr repository provider format: '{raw_id}'.") - value = raw_id[len(prefix):] + value = raw_id[len(prefix) :] if not value: raise ValueError("Missing Copr repository name.") From 4cc6c091ab55f14031eaed1c10952d75444ce420 Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Mon, 30 Mar 2026 10:58:48 +0100 Subject: [PATCH 08/31] address comments... --- .../prepare/artifact/providers/__init__.py | 235 +++++++++++++++++- 1 file changed, 230 insertions(+), 5 deletions(-) diff --git a/tmt/steps/prepare/artifact/providers/__init__.py b/tmt/steps/prepare/artifact/providers/__init__.py index 07335bd048..d8d47fb250 100644 --- a/tmt/steps/prepare/artifact/providers/__init__.py +++ b/tmt/steps/prepare/artifact/providers/__init__.py @@ -2,22 +2,22 @@ Abstract base class for artifact providers. """ +import configparser import re from abc import ABC, abstractmethod from collections.abc import Iterator, Sequence from re import Pattern from shlex import quote from typing import Any, Optional +from urllib.parse import urlparse import tmt.log import tmt.utils -from tmt._compat.typing import TypeAlias -from tmt.container import container +from tmt._compat.typing import Self, TypeAlias +from tmt.container import container, simple_field from tmt.guest import Guest -from tmt.package_managers import Repository, Version -from tmt.package_managers._rpm import RpmVersion from tmt.plugins import PluginRegistry -from tmt.utils import Path, ShellScript +from tmt.utils import GeneralError, Path, ShellScript NEVRA_PATTERN = re.compile( r'^(?P.+)-(?:(?P\d+):)?(?P.+)-(?P.+)\.(?P.+)$' @@ -36,6 +36,111 @@ class UnsupportedOperationError(RuntimeError): """ +@container(frozen=True) +class Version: + """ + Version information for artifacts. + """ + + name: str + version: str + release: str + arch: str + epoch: int = 0 + + @property + def nvra(self) -> str: + return f"{self.name}-{self.version}-{self.release}.{self.arch}" + + @property + def nevra(self) -> str: + return f"{self.name}-{self.epoch}:{self.version}-{self.release}.{self.arch}" + + def __str__(self) -> str: + return self.nvra + + +@container(frozen=True) +class RpmVersion(Version): + """ + Represents an RPM package version. + """ + + @classmethod + def from_rpm_meta(cls, rpm_meta: dict[str, Any]) -> Self: + """ + Version constructed from RPM metadata dictionary. + + Example usage: + + .. code-block:: python + + version_info = RpmVersion.from_rpm_meta({ + "name": "curl", + "version": "8.11.1", + "release": "7.fc42", + "arch": "x86_64" + }) + """ + return cls( + name=rpm_meta["name"], + version=rpm_meta["version"], + release=rpm_meta["release"], + arch=rpm_meta["arch"], + epoch=rpm_meta.get("epoch", 0), + ) + + @classmethod + def from_nevra( + cls, nevra: str + ) -> Self: # TODO: move this to `tmt.package_managers.PackageManager.list_packages` + """ + Version constructed from a NEVRA string as returned by ``dnf repoquery``. + + Example usage: + + .. code-block:: python + + version_info = RpmVersion.from_nevra("curl-0:8.11.1-7.fc42.x86_64") + version_info = RpmVersion.from_nevra("curl-8.11.1-7.fc42.x86_64") + """ + match = NEVRA_PATTERN.match(nevra) + if not match: + raise ValueError(f"Cannot parse NEVRA '{nevra}'.") + + return cls( + name=match.group('name'), + epoch=int(match.group('epoch') or 0), + version=match.group('version'), + release=match.group('release'), + arch=match.group('arch'), + ) + + @classmethod + def from_filename(cls, filename: str) -> Self: + """ + Version constructed from RPM filename. + + Example usage: + + .. code-block:: python + + version_info = RpmVersion.from_filename("curl-8.11.1-7.fc42.x86_64.rpm") + """ + base = filename.removesuffix(".rpm") + nvr_part, *arch_parts = base.rsplit(".", 1) + arch = arch_parts[0] if arch_parts else "noarch" + parts = nvr_part.rsplit("-", 2) + if len(parts) != 3: + raise ValueError( + f"Invalid RPM filename format: '{filename}'. " + f"Expected name-version-release.arch.rpm" + ) + name, version, release = parts + + return cls(name=name, version=version, release=release, arch=arch, epoch=0) + + @container class ArtifactInfo: """ @@ -314,6 +419,126 @@ def artifact_metadata(self) -> list[dict[str, Any]]: ] +@container +class Repository: + """ + Thin wrapper/holder for .repo file content + """ + + #: Content of the repository + content: str + #: Uniquely identifiable name + name: str + #: repository_ids present in the .repo file + repo_ids: list[str] = simple_field(default_factory=list[str]) + + def __post_init__(self) -> None: + """ + Extract repository IDs from the .repo file content after initialization. + + :raises GeneralError: If the content is malformed or no repository + sections are found. + """ + config = configparser.ConfigParser() + try: + config.read_string(self.content) + sections = config.sections() + if not sections: + raise GeneralError( + f"No repository sections found in the content for '{self.name}'." + ) + # Store the parsed sections in our private attribute + self.repo_ids = sections + except configparser.MissingSectionHeaderError as error: + raise GeneralError( + f"No repository sections found in the content for '{self.name}'." + ) from error + except configparser.Error as error: + raise GeneralError( + f"Failed to parse the content of repository '{self.name}'. " + "The .repo file may be malformed." + ) from error + + @classmethod + def from_url( + cls, url: str, logger: tmt.log.Logger, name: Optional[str] = None + ) -> "Repository": + """ + Create a Repository instance by fetching content from a URL. + + :param url: The URL to fetch the repository content from. + :param logger: Logger to use for the operation. + :param name: Optional name for the repository. If not provided, + derived from the URL. + :returns: A Repository instance. + :raises GeneralError: If fetching or parsing fails. + """ + try: + with tmt.utils.retry_session(logger=logger) as session: + response = session.get(url) + response.raise_for_status() + content = response.text + except Exception as error: + raise GeneralError(f"Failed to fetch repository content from '{url}'.") from error + + if name is None: + parsed_url = urlparse(url) + parsed_path = parsed_url.path.rstrip('/').split('/')[-1] + name = parsed_path.removesuffix('.repo') + if not name: + raise GeneralError(f"Could not derive repository name from URL '{url}'.") + + return cls(name=name, content=content) + + @classmethod + def from_file_path( + cls, file_path: Path, logger: tmt.log.Logger, name: Optional[str] = None + ) -> "Repository": + """ + Create a Repository instance by reading content from a local file path. + + :param file_path: The local path to the repository file. + :param logger: Logger to use for the operation. + :param name: Optional name for the repository. If not provided, + derived from the file path. + :returns: A Repository instance. + :raises GeneralError: If reading the file fails. + """ + try: + content = file_path.read_text() + except OSError as error: + raise GeneralError(f"Failed to read repository file '{file_path}'.") from error + + if name is None: + name = file_path.stem + if not name: + raise GeneralError( + f"Could not derive repository name from file path '{file_path}'." + ) + + return cls(name=name, content=content) + + @classmethod + def from_content(cls, content: str, name: str, logger: tmt.log.Logger) -> "Repository": + """ + Create a Repository instance directly from provided content string. + + :param content: The string content of the repository. + :param name: The name for the repository (required when using content). + :param logger: Logger to use for the operation. + :returns: A Repository instance. + :raises GeneralError: If the name is empty. + """ + if not name: + raise GeneralError("Repository name cannot be empty.") + return cls(name=name, content=content) + + @property + def filename(self) -> str: + """The name of the .repo file (e.g., 'my-repo.repo').""" + return f"{tmt.utils.sanitize_name(self.name)}.repo" + + _PROVIDER_REGISTRY: PluginRegistry[type[ArtifactProvider]] = PluginRegistry( 'prepare.artifact.providers' ) From 39949af70ac01682d403e106782eeba3fdbae636 Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Mon, 30 Mar 2026 12:58:55 +0100 Subject: [PATCH 09/31] (probable)fix: failing tests... --- tmt/steps/prepare/artifact/providers/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tmt/steps/prepare/artifact/providers/__init__.py b/tmt/steps/prepare/artifact/providers/__init__.py index d8d47fb250..25df8daa4f 100644 --- a/tmt/steps/prepare/artifact/providers/__init__.py +++ b/tmt/steps/prepare/artifact/providers/__init__.py @@ -536,7 +536,7 @@ def from_content(cls, content: str, name: str, logger: tmt.log.Logger) -> "Repos @property def filename(self) -> str: """The name of the .repo file (e.g., 'my-repo.repo').""" - return f"{tmt.utils.sanitize_name(self.name)}.repo" + return f"{tmt.utils.sanitize_name(self.name, allow_slash=False)}.repo" _PROVIDER_REGISTRY: PluginRegistry[type[ArtifactProvider]] = PluginRegistry( From b79e1594459bbb4d885bcd60db93e0c113f9f68a Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Mon, 30 Mar 2026 21:19:25 +0100 Subject: [PATCH 10/31] rebasing/conflicts... --- .../prepare/artifact/providers/__init__.py | 235 +----------------- 1 file changed, 5 insertions(+), 230 deletions(-) diff --git a/tmt/steps/prepare/artifact/providers/__init__.py b/tmt/steps/prepare/artifact/providers/__init__.py index 25df8daa4f..07335bd048 100644 --- a/tmt/steps/prepare/artifact/providers/__init__.py +++ b/tmt/steps/prepare/artifact/providers/__init__.py @@ -2,22 +2,22 @@ Abstract base class for artifact providers. """ -import configparser import re from abc import ABC, abstractmethod from collections.abc import Iterator, Sequence from re import Pattern from shlex import quote from typing import Any, Optional -from urllib.parse import urlparse import tmt.log import tmt.utils -from tmt._compat.typing import Self, TypeAlias -from tmt.container import container, simple_field +from tmt._compat.typing import TypeAlias +from tmt.container import container from tmt.guest import Guest +from tmt.package_managers import Repository, Version +from tmt.package_managers._rpm import RpmVersion from tmt.plugins import PluginRegistry -from tmt.utils import GeneralError, Path, ShellScript +from tmt.utils import Path, ShellScript NEVRA_PATTERN = re.compile( r'^(?P.+)-(?:(?P\d+):)?(?P.+)-(?P.+)\.(?P.+)$' @@ -36,111 +36,6 @@ class UnsupportedOperationError(RuntimeError): """ -@container(frozen=True) -class Version: - """ - Version information for artifacts. - """ - - name: str - version: str - release: str - arch: str - epoch: int = 0 - - @property - def nvra(self) -> str: - return f"{self.name}-{self.version}-{self.release}.{self.arch}" - - @property - def nevra(self) -> str: - return f"{self.name}-{self.epoch}:{self.version}-{self.release}.{self.arch}" - - def __str__(self) -> str: - return self.nvra - - -@container(frozen=True) -class RpmVersion(Version): - """ - Represents an RPM package version. - """ - - @classmethod - def from_rpm_meta(cls, rpm_meta: dict[str, Any]) -> Self: - """ - Version constructed from RPM metadata dictionary. - - Example usage: - - .. code-block:: python - - version_info = RpmVersion.from_rpm_meta({ - "name": "curl", - "version": "8.11.1", - "release": "7.fc42", - "arch": "x86_64" - }) - """ - return cls( - name=rpm_meta["name"], - version=rpm_meta["version"], - release=rpm_meta["release"], - arch=rpm_meta["arch"], - epoch=rpm_meta.get("epoch", 0), - ) - - @classmethod - def from_nevra( - cls, nevra: str - ) -> Self: # TODO: move this to `tmt.package_managers.PackageManager.list_packages` - """ - Version constructed from a NEVRA string as returned by ``dnf repoquery``. - - Example usage: - - .. code-block:: python - - version_info = RpmVersion.from_nevra("curl-0:8.11.1-7.fc42.x86_64") - version_info = RpmVersion.from_nevra("curl-8.11.1-7.fc42.x86_64") - """ - match = NEVRA_PATTERN.match(nevra) - if not match: - raise ValueError(f"Cannot parse NEVRA '{nevra}'.") - - return cls( - name=match.group('name'), - epoch=int(match.group('epoch') or 0), - version=match.group('version'), - release=match.group('release'), - arch=match.group('arch'), - ) - - @classmethod - def from_filename(cls, filename: str) -> Self: - """ - Version constructed from RPM filename. - - Example usage: - - .. code-block:: python - - version_info = RpmVersion.from_filename("curl-8.11.1-7.fc42.x86_64.rpm") - """ - base = filename.removesuffix(".rpm") - nvr_part, *arch_parts = base.rsplit(".", 1) - arch = arch_parts[0] if arch_parts else "noarch" - parts = nvr_part.rsplit("-", 2) - if len(parts) != 3: - raise ValueError( - f"Invalid RPM filename format: '{filename}'. " - f"Expected name-version-release.arch.rpm" - ) - name, version, release = parts - - return cls(name=name, version=version, release=release, arch=arch, epoch=0) - - @container class ArtifactInfo: """ @@ -419,126 +314,6 @@ def artifact_metadata(self) -> list[dict[str, Any]]: ] -@container -class Repository: - """ - Thin wrapper/holder for .repo file content - """ - - #: Content of the repository - content: str - #: Uniquely identifiable name - name: str - #: repository_ids present in the .repo file - repo_ids: list[str] = simple_field(default_factory=list[str]) - - def __post_init__(self) -> None: - """ - Extract repository IDs from the .repo file content after initialization. - - :raises GeneralError: If the content is malformed or no repository - sections are found. - """ - config = configparser.ConfigParser() - try: - config.read_string(self.content) - sections = config.sections() - if not sections: - raise GeneralError( - f"No repository sections found in the content for '{self.name}'." - ) - # Store the parsed sections in our private attribute - self.repo_ids = sections - except configparser.MissingSectionHeaderError as error: - raise GeneralError( - f"No repository sections found in the content for '{self.name}'." - ) from error - except configparser.Error as error: - raise GeneralError( - f"Failed to parse the content of repository '{self.name}'. " - "The .repo file may be malformed." - ) from error - - @classmethod - def from_url( - cls, url: str, logger: tmt.log.Logger, name: Optional[str] = None - ) -> "Repository": - """ - Create a Repository instance by fetching content from a URL. - - :param url: The URL to fetch the repository content from. - :param logger: Logger to use for the operation. - :param name: Optional name for the repository. If not provided, - derived from the URL. - :returns: A Repository instance. - :raises GeneralError: If fetching or parsing fails. - """ - try: - with tmt.utils.retry_session(logger=logger) as session: - response = session.get(url) - response.raise_for_status() - content = response.text - except Exception as error: - raise GeneralError(f"Failed to fetch repository content from '{url}'.") from error - - if name is None: - parsed_url = urlparse(url) - parsed_path = parsed_url.path.rstrip('/').split('/')[-1] - name = parsed_path.removesuffix('.repo') - if not name: - raise GeneralError(f"Could not derive repository name from URL '{url}'.") - - return cls(name=name, content=content) - - @classmethod - def from_file_path( - cls, file_path: Path, logger: tmt.log.Logger, name: Optional[str] = None - ) -> "Repository": - """ - Create a Repository instance by reading content from a local file path. - - :param file_path: The local path to the repository file. - :param logger: Logger to use for the operation. - :param name: Optional name for the repository. If not provided, - derived from the file path. - :returns: A Repository instance. - :raises GeneralError: If reading the file fails. - """ - try: - content = file_path.read_text() - except OSError as error: - raise GeneralError(f"Failed to read repository file '{file_path}'.") from error - - if name is None: - name = file_path.stem - if not name: - raise GeneralError( - f"Could not derive repository name from file path '{file_path}'." - ) - - return cls(name=name, content=content) - - @classmethod - def from_content(cls, content: str, name: str, logger: tmt.log.Logger) -> "Repository": - """ - Create a Repository instance directly from provided content string. - - :param content: The string content of the repository. - :param name: The name for the repository (required when using content). - :param logger: Logger to use for the operation. - :returns: A Repository instance. - :raises GeneralError: If the name is empty. - """ - if not name: - raise GeneralError("Repository name cannot be empty.") - return cls(name=name, content=content) - - @property - def filename(self) -> str: - """The name of the .repo file (e.g., 'my-repo.repo').""" - return f"{tmt.utils.sanitize_name(self.name, allow_slash=False)}.repo" - - _PROVIDER_REGISTRY: PluginRegistry[type[ArtifactProvider]] = PluginRegistry( 'prepare.artifact.providers' ) From 46d9854e0042365f413a7f25505384c5631a9f4b Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Thu, 19 Mar 2026 22:03:37 +0000 Subject: [PATCH 11/31] refactor copr repo provider to facilitate listing packages --- tmt/steps/prepare/artifact/providers/copr_repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tmt/steps/prepare/artifact/providers/copr_repository.py b/tmt/steps/prepare/artifact/providers/copr_repository.py index f763205c9e..c08db71c94 100644 --- a/tmt/steps/prepare/artifact/providers/copr_repository.py +++ b/tmt/steps/prepare/artifact/providers/copr_repository.py @@ -79,7 +79,7 @@ def _extract_provider_id(cls, raw_id: str) -> ArtifactProviderId: if not raw_id.startswith(prefix): raise ValueError(f"Invalid Copr repository provider format: '{raw_id}'.") - value = raw_id[len(prefix) :] + value = raw_id[len(prefix):] if not value: raise ValueError("Missing Copr repository name.") From 939f34a7f4fa09e90e31dd839cfcd503ccb19ea9 Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Fri, 13 Mar 2026 13:59:54 +0000 Subject: [PATCH 12/31] refactor copr repo provider to fetch .repo file via api --- tmt/steps/prepare/artifact/providers/_copr.py | 92 +++++++++++++++++++ .../prepare/artifact/providers/copr_build.py | 70 +++----------- 2 files changed, 106 insertions(+), 56 deletions(-) create mode 100644 tmt/steps/prepare/artifact/providers/_copr.py diff --git a/tmt/steps/prepare/artifact/providers/_copr.py b/tmt/steps/prepare/artifact/providers/_copr.py new file mode 100644 index 0000000000..651bc3e206 --- /dev/null +++ b/tmt/steps/prepare/artifact/providers/_copr.py @@ -0,0 +1,92 @@ +""" +Shared COPR utilities for copr-based artifact providers. +""" + +import types +from abc import abstractmethod +from functools import cached_property +from typing import Any, Optional + +import tmt.log +import tmt.utils +import tmt.utils.hints +from tmt.steps.prepare.artifact.providers import ArtifactProvider + +copr: Optional[types.ModuleType] = None + +# To silence mypy +Client: Any + +tmt.utils.hints.register_hint( + 'artifact-provider/copr', + """ +The ``copr`` Python package is required by tmt for Copr integration. + +To quickly test Copr presence, you can try running: + + python -c 'import copr' + +* Users who installed tmt from PyPI should install the ``copr`` package + via ``pip install copr``. +""", +) + + +def import_copr(logger: tmt.log.Logger) -> None: + """Import copr module with error handling.""" + global copr, Client + try: + import copr + from copr.v3 import Client + except ImportError as error: + from tmt.utils.hints import print_hints + + print_hints('artifact-provider/copr', logger=logger) + + raise tmt.utils.GeneralError("Could not import copr package.") from error + + +class CoprArtifactProvider(ArtifactProvider): + """ + Base class for COPR-based artifact providers. + """ + + def __init__(self, raw_id: str, repository_priority: int, logger: tmt.log.Logger) -> None: + super().__init__(raw_id, repository_priority, logger) + self._session = self._initialize_session() + + def _initialize_session(self) -> 'Client': + """ + Initialize copr client session. + """ + import_copr(self.logger) + + try: + config = {"copr_url": "https://copr.fedorainfracloud.org"} + return Client(config) + except Exception as error: + raise tmt.utils.GeneralError("Failed to initialize Copr client session.") from error + + @property + @abstractmethod + def _copr_owner(self) -> str: + raise NotImplementedError + + @property + @abstractmethod + def _copr_project(self) -> str: + raise NotImplementedError + + @cached_property + def project_info(self) -> Any: + """ + Fetch and return the COPR project metadata. + """ + try: + return self._session.project_proxy.get( + ownername=self._copr_owner, projectname=self._copr_project + ) + except Exception as error: + raise tmt.utils.GeneralError( + f"Failed to fetch COPR project info for '{self._copr_owner}/{self._copr_project}'." + ) from error diff --git a/tmt/steps/prepare/artifact/providers/copr_build.py b/tmt/steps/prepare/artifact/providers/copr_build.py index a8e28a095e..8efea05812 100644 --- a/tmt/steps/prepare/artifact/providers/copr_build.py +++ b/tmt/steps/prepare/artifact/providers/copr_build.py @@ -2,66 +2,31 @@ Copr Build Artifact Provider """ -import types from collections.abc import Sequence from functools import cached_property from shlex import quote -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Optional from urllib.parse import urljoin import tmt.log import tmt.utils -import tmt.utils.hints from tmt.guest import Guest from tmt.package_managers._rpm import RpmVersion from tmt.steps.prepare.artifact.providers import ( ArtifactInfo, - ArtifactProvider, ArtifactProviderId, DownloadError, provides_artifact_provider, ) +from tmt.steps.prepare.artifact.providers._copr import CoprArtifactProvider from tmt.utils import ShellScript if TYPE_CHECKING: from munch import Munch -copr: Optional[types.ModuleType] = None - -# To silence mypy -Client: Any - -tmt.utils.hints.register_hint( - 'artifact-provider/copr', - """ -The ``copr`` Python package is required by tmt for Copr integration. - -To quickly test Copr presence, you can try running: - - python -c 'import copr' - -* Users who installed tmt from PyPI should install the ``copr`` package - via ``pip install copr``. -""", -) - - -def import_copr(logger: tmt.log.Logger) -> None: - """Import copr module with error handling.""" - global copr, Client - try: - import copr - from copr.v3 import Client - except ImportError as error: - from tmt.utils.hints import print_hints - - print_hints('artifact-provider/copr', logger=logger) - - raise tmt.utils.GeneralError("Could not import copr package.") from error - @provides_artifact_provider("copr.build") -class CoprBuildArtifactProvider(ArtifactProvider): +class CoprBuildArtifactProvider(CoprArtifactProvider): """ Provider for downloading artifacts from Copr builds. @@ -80,7 +45,6 @@ class CoprBuildArtifactProvider(ArtifactProvider): def __init__(self, raw_id: str, repository_priority: int, logger: tmt.log.Logger): super().__init__(raw_id, repository_priority, logger) - self._session = self._initialize_session() try: build_id_str, chroot = self.id.split(":", 1) self.build_id = int(build_id_str) @@ -97,28 +61,22 @@ def build_info(self) -> Optional["Munch"]: """ return self._session.build_proxy.get(self.build_id) + @property + def _copr_owner(self) -> str: + assert self.build_info is not None + return str(self.build_info.ownername) + + @property + def _copr_project(self) -> str: + assert self.build_info is not None + return str(self.build_info.projectname) + @cached_property def is_pulp(self) -> bool: """ Check if the build is stored in Pulp. """ - assert self.build_info is not None - project = self._session.project_proxy.get( - self.build_info.ownername, self.build_info.projectname - ) - return project is not None and project.storage == "pulp" - - def _initialize_session(self) -> 'Client': - """ - Initialize copr client session. - """ - import_copr(self.logger) - - try: - config = {"copr_url": "https://copr.fedorainfracloud.org"} - return Client(config) - except Exception as error: - raise tmt.utils.GeneralError("Failed to initialize Copr client session.") from error + return self.project_info is not None and self.project_info.storage == "pulp" @classmethod def _extract_provider_id(cls, raw_id: str) -> ArtifactProviderId: From d5366061ff0f6bd785e94c1171b686917d4ad567 Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Mon, 16 Mar 2026 10:51:01 +0000 Subject: [PATCH 13/31] refactor: move COPR utilities to the sharred module --- tmt/steps/prepare/artifact/providers/_copr.py | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/tmt/steps/prepare/artifact/providers/_copr.py b/tmt/steps/prepare/artifact/providers/_copr.py index 651bc3e206..e807fea12d 100644 --- a/tmt/steps/prepare/artifact/providers/_copr.py +++ b/tmt/steps/prepare/artifact/providers/_copr.py @@ -1,7 +1,8 @@ """ -Shared COPR utilities for copr-based artifact providers. +Shared COPR utilities. """ +import re import types from abc import abstractmethod from functools import cached_property @@ -32,6 +33,33 @@ ) +COPR_URL = 'https://copr.fedorainfracloud.org/coprs' +COPR_REPO_PATTERN = re.compile(r'^(@)?([^/]+)/([^/]+)$') + + +def parse_copr_repo(copr_repo: str) -> tuple[bool, str, str]: + """ + Parse a COPR repository identifier into its components. + """ + matched = COPR_REPO_PATTERN.match(copr_repo) + if not matched: + raise tmt.utils.PrepareError(f"Invalid copr repository '{copr_repo}'.") + is_group, name, project = matched.groups() + return bool(is_group), name, project + + +def build_copr_repo_url(copr_repo: str, chroot: str) -> str: + """ + Construct the URL for a COPR ``.repo`` file. + """ + is_group, name, project = parse_copr_repo(copr_repo) + group = 'group_' if is_group else '' + parts = [COPR_URL] + (['g'] if is_group else []) + parts += [name, project, 'repo', chroot] + parts += [f"{group}{name}-{project}-{chroot}.repo"] + return '/'.join(parts) + + def import_copr(logger: tmt.log.Logger) -> None: """Import copr module with error handling.""" global copr, Client From f9ab54888edf3c5a121a4a06f9cefef870727564 Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Fri, 20 Mar 2026 11:45:19 +0000 Subject: [PATCH 14/31] rebasing... --- tmt/steps/prepare/artifact/providers/copr_repository.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tmt/steps/prepare/artifact/providers/copr_repository.py b/tmt/steps/prepare/artifact/providers/copr_repository.py index c08db71c94..6cb047bbde 100644 --- a/tmt/steps/prepare/artifact/providers/copr_repository.py +++ b/tmt/steps/prepare/artifact/providers/copr_repository.py @@ -96,6 +96,11 @@ def get_repositories(self) -> list[Repository]: return [] return [self.repository] + def get_repositories(self) -> list[Repository]: + if self.repository is None: + return [] + return [self.repository] + def _download_artifact(self, artifact: ArtifactInfo, guest: Guest, destination: Path) -> None: """This provider only enables repositories; it does not download individual RPMs.""" raise UnsupportedOperationError( From 374e1f61fa73651cd55e45ff758a1702fc13d85a Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Fri, 20 Mar 2026 11:49:22 +0000 Subject: [PATCH 15/31] more rebasing... --- tmt/steps/prepare/artifact/providers/_copr.py | 120 ------------------ .../prepare/artifact/providers/copr_build.py | 70 ++++++++-- 2 files changed, 56 insertions(+), 134 deletions(-) delete mode 100644 tmt/steps/prepare/artifact/providers/_copr.py diff --git a/tmt/steps/prepare/artifact/providers/_copr.py b/tmt/steps/prepare/artifact/providers/_copr.py deleted file mode 100644 index e807fea12d..0000000000 --- a/tmt/steps/prepare/artifact/providers/_copr.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -Shared COPR utilities. -""" - -import re -import types -from abc import abstractmethod -from functools import cached_property -from typing import Any, Optional - -import tmt.log -import tmt.utils -import tmt.utils.hints -from tmt.steps.prepare.artifact.providers import ArtifactProvider - -copr: Optional[types.ModuleType] = None - -# To silence mypy -Client: Any - -tmt.utils.hints.register_hint( - 'artifact-provider/copr', - """ -The ``copr`` Python package is required by tmt for Copr integration. - -To quickly test Copr presence, you can try running: - - python -c 'import copr' - -* Users who installed tmt from PyPI should install the ``copr`` package - via ``pip install copr``. -""", -) - - -COPR_URL = 'https://copr.fedorainfracloud.org/coprs' -COPR_REPO_PATTERN = re.compile(r'^(@)?([^/]+)/([^/]+)$') - - -def parse_copr_repo(copr_repo: str) -> tuple[bool, str, str]: - """ - Parse a COPR repository identifier into its components. - """ - matched = COPR_REPO_PATTERN.match(copr_repo) - if not matched: - raise tmt.utils.PrepareError(f"Invalid copr repository '{copr_repo}'.") - is_group, name, project = matched.groups() - return bool(is_group), name, project - - -def build_copr_repo_url(copr_repo: str, chroot: str) -> str: - """ - Construct the URL for a COPR ``.repo`` file. - """ - is_group, name, project = parse_copr_repo(copr_repo) - group = 'group_' if is_group else '' - parts = [COPR_URL] + (['g'] if is_group else []) - parts += [name, project, 'repo', chroot] - parts += [f"{group}{name}-{project}-{chroot}.repo"] - return '/'.join(parts) - - -def import_copr(logger: tmt.log.Logger) -> None: - """Import copr module with error handling.""" - global copr, Client - try: - import copr - from copr.v3 import Client - except ImportError as error: - from tmt.utils.hints import print_hints - - print_hints('artifact-provider/copr', logger=logger) - - raise tmt.utils.GeneralError("Could not import copr package.") from error - - -class CoprArtifactProvider(ArtifactProvider): - """ - Base class for COPR-based artifact providers. - """ - - def __init__(self, raw_id: str, repository_priority: int, logger: tmt.log.Logger) -> None: - super().__init__(raw_id, repository_priority, logger) - self._session = self._initialize_session() - - def _initialize_session(self) -> 'Client': - """ - Initialize copr client session. - """ - import_copr(self.logger) - - try: - config = {"copr_url": "https://copr.fedorainfracloud.org"} - return Client(config) - except Exception as error: - raise tmt.utils.GeneralError("Failed to initialize Copr client session.") from error - - @property - @abstractmethod - def _copr_owner(self) -> str: - raise NotImplementedError - - @property - @abstractmethod - def _copr_project(self) -> str: - raise NotImplementedError - - @cached_property - def project_info(self) -> Any: - """ - Fetch and return the COPR project metadata. - """ - try: - return self._session.project_proxy.get( - ownername=self._copr_owner, projectname=self._copr_project - ) - except Exception as error: - raise tmt.utils.GeneralError( - f"Failed to fetch COPR project info for '{self._copr_owner}/{self._copr_project}'." - ) from error diff --git a/tmt/steps/prepare/artifact/providers/copr_build.py b/tmt/steps/prepare/artifact/providers/copr_build.py index 8efea05812..a8e28a095e 100644 --- a/tmt/steps/prepare/artifact/providers/copr_build.py +++ b/tmt/steps/prepare/artifact/providers/copr_build.py @@ -2,31 +2,66 @@ Copr Build Artifact Provider """ +import types from collections.abc import Sequence from functools import cached_property from shlex import quote -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Any, Optional from urllib.parse import urljoin import tmt.log import tmt.utils +import tmt.utils.hints from tmt.guest import Guest from tmt.package_managers._rpm import RpmVersion from tmt.steps.prepare.artifact.providers import ( ArtifactInfo, + ArtifactProvider, ArtifactProviderId, DownloadError, provides_artifact_provider, ) -from tmt.steps.prepare.artifact.providers._copr import CoprArtifactProvider from tmt.utils import ShellScript if TYPE_CHECKING: from munch import Munch +copr: Optional[types.ModuleType] = None + +# To silence mypy +Client: Any + +tmt.utils.hints.register_hint( + 'artifact-provider/copr', + """ +The ``copr`` Python package is required by tmt for Copr integration. + +To quickly test Copr presence, you can try running: + + python -c 'import copr' + +* Users who installed tmt from PyPI should install the ``copr`` package + via ``pip install copr``. +""", +) + + +def import_copr(logger: tmt.log.Logger) -> None: + """Import copr module with error handling.""" + global copr, Client + try: + import copr + from copr.v3 import Client + except ImportError as error: + from tmt.utils.hints import print_hints + + print_hints('artifact-provider/copr', logger=logger) + + raise tmt.utils.GeneralError("Could not import copr package.") from error + @provides_artifact_provider("copr.build") -class CoprBuildArtifactProvider(CoprArtifactProvider): +class CoprBuildArtifactProvider(ArtifactProvider): """ Provider for downloading artifacts from Copr builds. @@ -45,6 +80,7 @@ class CoprBuildArtifactProvider(CoprArtifactProvider): def __init__(self, raw_id: str, repository_priority: int, logger: tmt.log.Logger): super().__init__(raw_id, repository_priority, logger) + self._session = self._initialize_session() try: build_id_str, chroot = self.id.split(":", 1) self.build_id = int(build_id_str) @@ -61,22 +97,28 @@ def build_info(self) -> Optional["Munch"]: """ return self._session.build_proxy.get(self.build_id) - @property - def _copr_owner(self) -> str: - assert self.build_info is not None - return str(self.build_info.ownername) - - @property - def _copr_project(self) -> str: - assert self.build_info is not None - return str(self.build_info.projectname) - @cached_property def is_pulp(self) -> bool: """ Check if the build is stored in Pulp. """ - return self.project_info is not None and self.project_info.storage == "pulp" + assert self.build_info is not None + project = self._session.project_proxy.get( + self.build_info.ownername, self.build_info.projectname + ) + return project is not None and project.storage == "pulp" + + def _initialize_session(self) -> 'Client': + """ + Initialize copr client session. + """ + import_copr(self.logger) + + try: + config = {"copr_url": "https://copr.fedorainfracloud.org"} + return Client(config) + except Exception as error: + raise tmt.utils.GeneralError("Failed to initialize Copr client session.") from error @classmethod def _extract_provider_id(cls, raw_id: str) -> ArtifactProviderId: From ae5e5b54e5fcc52445c99ed6a0c3acf309bd455d Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Tue, 24 Mar 2026 12:46:45 +0000 Subject: [PATCH 16/31] address comments... --- tmt/steps/prepare/artifact/providers/copr_repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tmt/steps/prepare/artifact/providers/copr_repository.py b/tmt/steps/prepare/artifact/providers/copr_repository.py index 6cb047bbde..b98c0ca8a1 100644 --- a/tmt/steps/prepare/artifact/providers/copr_repository.py +++ b/tmt/steps/prepare/artifact/providers/copr_repository.py @@ -79,7 +79,7 @@ def _extract_provider_id(cls, raw_id: str) -> ArtifactProviderId: if not raw_id.startswith(prefix): raise ValueError(f"Invalid Copr repository provider format: '{raw_id}'.") - value = raw_id[len(prefix):] + value = raw_id[len(prefix) :] if not value: raise ValueError("Missing Copr repository name.") From c6826bab801abcfb5ea469f9719b0c15538ed4ad Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Thu, 19 Mar 2026 22:03:37 +0000 Subject: [PATCH 17/31] refactor copr repo provider to facilitate listing packages --- tmt/steps/prepare/artifact/providers/copr_repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tmt/steps/prepare/artifact/providers/copr_repository.py b/tmt/steps/prepare/artifact/providers/copr_repository.py index b98c0ca8a1..6cb047bbde 100644 --- a/tmt/steps/prepare/artifact/providers/copr_repository.py +++ b/tmt/steps/prepare/artifact/providers/copr_repository.py @@ -79,7 +79,7 @@ def _extract_provider_id(cls, raw_id: str) -> ArtifactProviderId: if not raw_id.startswith(prefix): raise ValueError(f"Invalid Copr repository provider format: '{raw_id}'.") - value = raw_id[len(prefix) :] + value = raw_id[len(prefix):] if not value: raise ValueError("Missing Copr repository name.") From 532ece0ea1d0d2088bcc56f7314d7f6aca9f35bb Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Fri, 13 Mar 2026 13:59:54 +0000 Subject: [PATCH 18/31] refactor copr repo provider to fetch .repo file via api --- tmt/steps/prepare/artifact/providers/_copr.py | 92 +++++++++++++++++++ .../prepare/artifact/providers/copr_build.py | 70 +++----------- 2 files changed, 106 insertions(+), 56 deletions(-) create mode 100644 tmt/steps/prepare/artifact/providers/_copr.py diff --git a/tmt/steps/prepare/artifact/providers/_copr.py b/tmt/steps/prepare/artifact/providers/_copr.py new file mode 100644 index 0000000000..651bc3e206 --- /dev/null +++ b/tmt/steps/prepare/artifact/providers/_copr.py @@ -0,0 +1,92 @@ +""" +Shared COPR utilities for copr-based artifact providers. +""" + +import types +from abc import abstractmethod +from functools import cached_property +from typing import Any, Optional + +import tmt.log +import tmt.utils +import tmt.utils.hints +from tmt.steps.prepare.artifact.providers import ArtifactProvider + +copr: Optional[types.ModuleType] = None + +# To silence mypy +Client: Any + +tmt.utils.hints.register_hint( + 'artifact-provider/copr', + """ +The ``copr`` Python package is required by tmt for Copr integration. + +To quickly test Copr presence, you can try running: + + python -c 'import copr' + +* Users who installed tmt from PyPI should install the ``copr`` package + via ``pip install copr``. +""", +) + + +def import_copr(logger: tmt.log.Logger) -> None: + """Import copr module with error handling.""" + global copr, Client + try: + import copr + from copr.v3 import Client + except ImportError as error: + from tmt.utils.hints import print_hints + + print_hints('artifact-provider/copr', logger=logger) + + raise tmt.utils.GeneralError("Could not import copr package.") from error + + +class CoprArtifactProvider(ArtifactProvider): + """ + Base class for COPR-based artifact providers. + """ + + def __init__(self, raw_id: str, repository_priority: int, logger: tmt.log.Logger) -> None: + super().__init__(raw_id, repository_priority, logger) + self._session = self._initialize_session() + + def _initialize_session(self) -> 'Client': + """ + Initialize copr client session. + """ + import_copr(self.logger) + + try: + config = {"copr_url": "https://copr.fedorainfracloud.org"} + return Client(config) + except Exception as error: + raise tmt.utils.GeneralError("Failed to initialize Copr client session.") from error + + @property + @abstractmethod + def _copr_owner(self) -> str: + raise NotImplementedError + + @property + @abstractmethod + def _copr_project(self) -> str: + raise NotImplementedError + + @cached_property + def project_info(self) -> Any: + """ + Fetch and return the COPR project metadata. + """ + try: + return self._session.project_proxy.get( + ownername=self._copr_owner, projectname=self._copr_project + ) + except Exception as error: + raise tmt.utils.GeneralError( + f"Failed to fetch COPR project info for '{self._copr_owner}/{self._copr_project}'." + ) from error diff --git a/tmt/steps/prepare/artifact/providers/copr_build.py b/tmt/steps/prepare/artifact/providers/copr_build.py index a8e28a095e..8efea05812 100644 --- a/tmt/steps/prepare/artifact/providers/copr_build.py +++ b/tmt/steps/prepare/artifact/providers/copr_build.py @@ -2,66 +2,31 @@ Copr Build Artifact Provider """ -import types from collections.abc import Sequence from functools import cached_property from shlex import quote -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Optional from urllib.parse import urljoin import tmt.log import tmt.utils -import tmt.utils.hints from tmt.guest import Guest from tmt.package_managers._rpm import RpmVersion from tmt.steps.prepare.artifact.providers import ( ArtifactInfo, - ArtifactProvider, ArtifactProviderId, DownloadError, provides_artifact_provider, ) +from tmt.steps.prepare.artifact.providers._copr import CoprArtifactProvider from tmt.utils import ShellScript if TYPE_CHECKING: from munch import Munch -copr: Optional[types.ModuleType] = None - -# To silence mypy -Client: Any - -tmt.utils.hints.register_hint( - 'artifact-provider/copr', - """ -The ``copr`` Python package is required by tmt for Copr integration. - -To quickly test Copr presence, you can try running: - - python -c 'import copr' - -* Users who installed tmt from PyPI should install the ``copr`` package - via ``pip install copr``. -""", -) - - -def import_copr(logger: tmt.log.Logger) -> None: - """Import copr module with error handling.""" - global copr, Client - try: - import copr - from copr.v3 import Client - except ImportError as error: - from tmt.utils.hints import print_hints - - print_hints('artifact-provider/copr', logger=logger) - - raise tmt.utils.GeneralError("Could not import copr package.") from error - @provides_artifact_provider("copr.build") -class CoprBuildArtifactProvider(ArtifactProvider): +class CoprBuildArtifactProvider(CoprArtifactProvider): """ Provider for downloading artifacts from Copr builds. @@ -80,7 +45,6 @@ class CoprBuildArtifactProvider(ArtifactProvider): def __init__(self, raw_id: str, repository_priority: int, logger: tmt.log.Logger): super().__init__(raw_id, repository_priority, logger) - self._session = self._initialize_session() try: build_id_str, chroot = self.id.split(":", 1) self.build_id = int(build_id_str) @@ -97,28 +61,22 @@ def build_info(self) -> Optional["Munch"]: """ return self._session.build_proxy.get(self.build_id) + @property + def _copr_owner(self) -> str: + assert self.build_info is not None + return str(self.build_info.ownername) + + @property + def _copr_project(self) -> str: + assert self.build_info is not None + return str(self.build_info.projectname) + @cached_property def is_pulp(self) -> bool: """ Check if the build is stored in Pulp. """ - assert self.build_info is not None - project = self._session.project_proxy.get( - self.build_info.ownername, self.build_info.projectname - ) - return project is not None and project.storage == "pulp" - - def _initialize_session(self) -> 'Client': - """ - Initialize copr client session. - """ - import_copr(self.logger) - - try: - config = {"copr_url": "https://copr.fedorainfracloud.org"} - return Client(config) - except Exception as error: - raise tmt.utils.GeneralError("Failed to initialize Copr client session.") from error + return self.project_info is not None and self.project_info.storage == "pulp" @classmethod def _extract_provider_id(cls, raw_id: str) -> ArtifactProviderId: From 74427ccc025d0c3958491fd84ae06ea6d60b57db Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Mon, 16 Mar 2026 10:51:01 +0000 Subject: [PATCH 19/31] refactor: move COPR utilities to the sharred module --- tmt/steps/prepare/artifact/providers/_copr.py | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/tmt/steps/prepare/artifact/providers/_copr.py b/tmt/steps/prepare/artifact/providers/_copr.py index 651bc3e206..e807fea12d 100644 --- a/tmt/steps/prepare/artifact/providers/_copr.py +++ b/tmt/steps/prepare/artifact/providers/_copr.py @@ -1,7 +1,8 @@ """ -Shared COPR utilities for copr-based artifact providers. +Shared COPR utilities. """ +import re import types from abc import abstractmethod from functools import cached_property @@ -32,6 +33,33 @@ ) +COPR_URL = 'https://copr.fedorainfracloud.org/coprs' +COPR_REPO_PATTERN = re.compile(r'^(@)?([^/]+)/([^/]+)$') + + +def parse_copr_repo(copr_repo: str) -> tuple[bool, str, str]: + """ + Parse a COPR repository identifier into its components. + """ + matched = COPR_REPO_PATTERN.match(copr_repo) + if not matched: + raise tmt.utils.PrepareError(f"Invalid copr repository '{copr_repo}'.") + is_group, name, project = matched.groups() + return bool(is_group), name, project + + +def build_copr_repo_url(copr_repo: str, chroot: str) -> str: + """ + Construct the URL for a COPR ``.repo`` file. + """ + is_group, name, project = parse_copr_repo(copr_repo) + group = 'group_' if is_group else '' + parts = [COPR_URL] + (['g'] if is_group else []) + parts += [name, project, 'repo', chroot] + parts += [f"{group}{name}-{project}-{chroot}.repo"] + return '/'.join(parts) + + def import_copr(logger: tmt.log.Logger) -> None: """Import copr module with error handling.""" global copr, Client From d5b0c23533b5e2d338265a728d198dc36c7923ca Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Fri, 20 Mar 2026 11:49:22 +0000 Subject: [PATCH 20/31] more rebasing... --- tmt/steps/prepare/artifact/providers/_copr.py | 120 ------------------ .../prepare/artifact/providers/copr_build.py | 70 ++++++++-- 2 files changed, 56 insertions(+), 134 deletions(-) delete mode 100644 tmt/steps/prepare/artifact/providers/_copr.py diff --git a/tmt/steps/prepare/artifact/providers/_copr.py b/tmt/steps/prepare/artifact/providers/_copr.py deleted file mode 100644 index e807fea12d..0000000000 --- a/tmt/steps/prepare/artifact/providers/_copr.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -Shared COPR utilities. -""" - -import re -import types -from abc import abstractmethod -from functools import cached_property -from typing import Any, Optional - -import tmt.log -import tmt.utils -import tmt.utils.hints -from tmt.steps.prepare.artifact.providers import ArtifactProvider - -copr: Optional[types.ModuleType] = None - -# To silence mypy -Client: Any - -tmt.utils.hints.register_hint( - 'artifact-provider/copr', - """ -The ``copr`` Python package is required by tmt for Copr integration. - -To quickly test Copr presence, you can try running: - - python -c 'import copr' - -* Users who installed tmt from PyPI should install the ``copr`` package - via ``pip install copr``. -""", -) - - -COPR_URL = 'https://copr.fedorainfracloud.org/coprs' -COPR_REPO_PATTERN = re.compile(r'^(@)?([^/]+)/([^/]+)$') - - -def parse_copr_repo(copr_repo: str) -> tuple[bool, str, str]: - """ - Parse a COPR repository identifier into its components. - """ - matched = COPR_REPO_PATTERN.match(copr_repo) - if not matched: - raise tmt.utils.PrepareError(f"Invalid copr repository '{copr_repo}'.") - is_group, name, project = matched.groups() - return bool(is_group), name, project - - -def build_copr_repo_url(copr_repo: str, chroot: str) -> str: - """ - Construct the URL for a COPR ``.repo`` file. - """ - is_group, name, project = parse_copr_repo(copr_repo) - group = 'group_' if is_group else '' - parts = [COPR_URL] + (['g'] if is_group else []) - parts += [name, project, 'repo', chroot] - parts += [f"{group}{name}-{project}-{chroot}.repo"] - return '/'.join(parts) - - -def import_copr(logger: tmt.log.Logger) -> None: - """Import copr module with error handling.""" - global copr, Client - try: - import copr - from copr.v3 import Client - except ImportError as error: - from tmt.utils.hints import print_hints - - print_hints('artifact-provider/copr', logger=logger) - - raise tmt.utils.GeneralError("Could not import copr package.") from error - - -class CoprArtifactProvider(ArtifactProvider): - """ - Base class for COPR-based artifact providers. - """ - - def __init__(self, raw_id: str, repository_priority: int, logger: tmt.log.Logger) -> None: - super().__init__(raw_id, repository_priority, logger) - self._session = self._initialize_session() - - def _initialize_session(self) -> 'Client': - """ - Initialize copr client session. - """ - import_copr(self.logger) - - try: - config = {"copr_url": "https://copr.fedorainfracloud.org"} - return Client(config) - except Exception as error: - raise tmt.utils.GeneralError("Failed to initialize Copr client session.") from error - - @property - @abstractmethod - def _copr_owner(self) -> str: - raise NotImplementedError - - @property - @abstractmethod - def _copr_project(self) -> str: - raise NotImplementedError - - @cached_property - def project_info(self) -> Any: - """ - Fetch and return the COPR project metadata. - """ - try: - return self._session.project_proxy.get( - ownername=self._copr_owner, projectname=self._copr_project - ) - except Exception as error: - raise tmt.utils.GeneralError( - f"Failed to fetch COPR project info for '{self._copr_owner}/{self._copr_project}'." - ) from error diff --git a/tmt/steps/prepare/artifact/providers/copr_build.py b/tmt/steps/prepare/artifact/providers/copr_build.py index 8efea05812..a8e28a095e 100644 --- a/tmt/steps/prepare/artifact/providers/copr_build.py +++ b/tmt/steps/prepare/artifact/providers/copr_build.py @@ -2,31 +2,66 @@ Copr Build Artifact Provider """ +import types from collections.abc import Sequence from functools import cached_property from shlex import quote -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Any, Optional from urllib.parse import urljoin import tmt.log import tmt.utils +import tmt.utils.hints from tmt.guest import Guest from tmt.package_managers._rpm import RpmVersion from tmt.steps.prepare.artifact.providers import ( ArtifactInfo, + ArtifactProvider, ArtifactProviderId, DownloadError, provides_artifact_provider, ) -from tmt.steps.prepare.artifact.providers._copr import CoprArtifactProvider from tmt.utils import ShellScript if TYPE_CHECKING: from munch import Munch +copr: Optional[types.ModuleType] = None + +# To silence mypy +Client: Any + +tmt.utils.hints.register_hint( + 'artifact-provider/copr', + """ +The ``copr`` Python package is required by tmt for Copr integration. + +To quickly test Copr presence, you can try running: + + python -c 'import copr' + +* Users who installed tmt from PyPI should install the ``copr`` package + via ``pip install copr``. +""", +) + + +def import_copr(logger: tmt.log.Logger) -> None: + """Import copr module with error handling.""" + global copr, Client + try: + import copr + from copr.v3 import Client + except ImportError as error: + from tmt.utils.hints import print_hints + + print_hints('artifact-provider/copr', logger=logger) + + raise tmt.utils.GeneralError("Could not import copr package.") from error + @provides_artifact_provider("copr.build") -class CoprBuildArtifactProvider(CoprArtifactProvider): +class CoprBuildArtifactProvider(ArtifactProvider): """ Provider for downloading artifacts from Copr builds. @@ -45,6 +80,7 @@ class CoprBuildArtifactProvider(CoprArtifactProvider): def __init__(self, raw_id: str, repository_priority: int, logger: tmt.log.Logger): super().__init__(raw_id, repository_priority, logger) + self._session = self._initialize_session() try: build_id_str, chroot = self.id.split(":", 1) self.build_id = int(build_id_str) @@ -61,22 +97,28 @@ def build_info(self) -> Optional["Munch"]: """ return self._session.build_proxy.get(self.build_id) - @property - def _copr_owner(self) -> str: - assert self.build_info is not None - return str(self.build_info.ownername) - - @property - def _copr_project(self) -> str: - assert self.build_info is not None - return str(self.build_info.projectname) - @cached_property def is_pulp(self) -> bool: """ Check if the build is stored in Pulp. """ - return self.project_info is not None and self.project_info.storage == "pulp" + assert self.build_info is not None + project = self._session.project_proxy.get( + self.build_info.ownername, self.build_info.projectname + ) + return project is not None and project.storage == "pulp" + + def _initialize_session(self) -> 'Client': + """ + Initialize copr client session. + """ + import_copr(self.logger) + + try: + config = {"copr_url": "https://copr.fedorainfracloud.org"} + return Client(config) + except Exception as error: + raise tmt.utils.GeneralError("Failed to initialize Copr client session.") from error @classmethod def _extract_provider_id(cls, raw_id: str) -> ArtifactProviderId: From 609d50d42fa8d26e4a7299e5b07e2f13c12a427b Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Tue, 24 Mar 2026 12:46:45 +0000 Subject: [PATCH 21/31] address comments... --- tmt/steps/prepare/artifact/providers/copr_repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tmt/steps/prepare/artifact/providers/copr_repository.py b/tmt/steps/prepare/artifact/providers/copr_repository.py index 6cb047bbde..b98c0ca8a1 100644 --- a/tmt/steps/prepare/artifact/providers/copr_repository.py +++ b/tmt/steps/prepare/artifact/providers/copr_repository.py @@ -79,7 +79,7 @@ def _extract_provider_id(cls, raw_id: str) -> ArtifactProviderId: if not raw_id.startswith(prefix): raise ValueError(f"Invalid Copr repository provider format: '{raw_id}'.") - value = raw_id[len(prefix):] + value = raw_id[len(prefix) :] if not value: raise ValueError("Missing Copr repository name.") From c0f07739e5cbe5df3cfb97c04ec6ce63ecbe2476 Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Thu, 19 Mar 2026 22:03:37 +0000 Subject: [PATCH 22/31] refactor copr repo provider to facilitate listing packages --- tmt/steps/prepare/artifact/providers/copr_repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tmt/steps/prepare/artifact/providers/copr_repository.py b/tmt/steps/prepare/artifact/providers/copr_repository.py index b98c0ca8a1..6cb047bbde 100644 --- a/tmt/steps/prepare/artifact/providers/copr_repository.py +++ b/tmt/steps/prepare/artifact/providers/copr_repository.py @@ -79,7 +79,7 @@ def _extract_provider_id(cls, raw_id: str) -> ArtifactProviderId: if not raw_id.startswith(prefix): raise ValueError(f"Invalid Copr repository provider format: '{raw_id}'.") - value = raw_id[len(prefix) :] + value = raw_id[len(prefix):] if not value: raise ValueError("Missing Copr repository name.") From a8652d2e6dd129608beef971aabebe33bc2c47b1 Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Fri, 13 Mar 2026 13:59:54 +0000 Subject: [PATCH 23/31] refactor copr repo provider to fetch .repo file via api --- tmt/steps/prepare/artifact/providers/_copr.py | 92 +++++++++++++++++++ .../prepare/artifact/providers/copr_build.py | 70 +++----------- 2 files changed, 106 insertions(+), 56 deletions(-) create mode 100644 tmt/steps/prepare/artifact/providers/_copr.py diff --git a/tmt/steps/prepare/artifact/providers/_copr.py b/tmt/steps/prepare/artifact/providers/_copr.py new file mode 100644 index 0000000000..651bc3e206 --- /dev/null +++ b/tmt/steps/prepare/artifact/providers/_copr.py @@ -0,0 +1,92 @@ +""" +Shared COPR utilities for copr-based artifact providers. +""" + +import types +from abc import abstractmethod +from functools import cached_property +from typing import Any, Optional + +import tmt.log +import tmt.utils +import tmt.utils.hints +from tmt.steps.prepare.artifact.providers import ArtifactProvider + +copr: Optional[types.ModuleType] = None + +# To silence mypy +Client: Any + +tmt.utils.hints.register_hint( + 'artifact-provider/copr', + """ +The ``copr`` Python package is required by tmt for Copr integration. + +To quickly test Copr presence, you can try running: + + python -c 'import copr' + +* Users who installed tmt from PyPI should install the ``copr`` package + via ``pip install copr``. +""", +) + + +def import_copr(logger: tmt.log.Logger) -> None: + """Import copr module with error handling.""" + global copr, Client + try: + import copr + from copr.v3 import Client + except ImportError as error: + from tmt.utils.hints import print_hints + + print_hints('artifact-provider/copr', logger=logger) + + raise tmt.utils.GeneralError("Could not import copr package.") from error + + +class CoprArtifactProvider(ArtifactProvider): + """ + Base class for COPR-based artifact providers. + """ + + def __init__(self, raw_id: str, repository_priority: int, logger: tmt.log.Logger) -> None: + super().__init__(raw_id, repository_priority, logger) + self._session = self._initialize_session() + + def _initialize_session(self) -> 'Client': + """ + Initialize copr client session. + """ + import_copr(self.logger) + + try: + config = {"copr_url": "https://copr.fedorainfracloud.org"} + return Client(config) + except Exception as error: + raise tmt.utils.GeneralError("Failed to initialize Copr client session.") from error + + @property + @abstractmethod + def _copr_owner(self) -> str: + raise NotImplementedError + + @property + @abstractmethod + def _copr_project(self) -> str: + raise NotImplementedError + + @cached_property + def project_info(self) -> Any: + """ + Fetch and return the COPR project metadata. + """ + try: + return self._session.project_proxy.get( + ownername=self._copr_owner, projectname=self._copr_project + ) + except Exception as error: + raise tmt.utils.GeneralError( + f"Failed to fetch COPR project info for '{self._copr_owner}/{self._copr_project}'." + ) from error diff --git a/tmt/steps/prepare/artifact/providers/copr_build.py b/tmt/steps/prepare/artifact/providers/copr_build.py index a8e28a095e..8efea05812 100644 --- a/tmt/steps/prepare/artifact/providers/copr_build.py +++ b/tmt/steps/prepare/artifact/providers/copr_build.py @@ -2,66 +2,31 @@ Copr Build Artifact Provider """ -import types from collections.abc import Sequence from functools import cached_property from shlex import quote -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Optional from urllib.parse import urljoin import tmt.log import tmt.utils -import tmt.utils.hints from tmt.guest import Guest from tmt.package_managers._rpm import RpmVersion from tmt.steps.prepare.artifact.providers import ( ArtifactInfo, - ArtifactProvider, ArtifactProviderId, DownloadError, provides_artifact_provider, ) +from tmt.steps.prepare.artifact.providers._copr import CoprArtifactProvider from tmt.utils import ShellScript if TYPE_CHECKING: from munch import Munch -copr: Optional[types.ModuleType] = None - -# To silence mypy -Client: Any - -tmt.utils.hints.register_hint( - 'artifact-provider/copr', - """ -The ``copr`` Python package is required by tmt for Copr integration. - -To quickly test Copr presence, you can try running: - - python -c 'import copr' - -* Users who installed tmt from PyPI should install the ``copr`` package - via ``pip install copr``. -""", -) - - -def import_copr(logger: tmt.log.Logger) -> None: - """Import copr module with error handling.""" - global copr, Client - try: - import copr - from copr.v3 import Client - except ImportError as error: - from tmt.utils.hints import print_hints - - print_hints('artifact-provider/copr', logger=logger) - - raise tmt.utils.GeneralError("Could not import copr package.") from error - @provides_artifact_provider("copr.build") -class CoprBuildArtifactProvider(ArtifactProvider): +class CoprBuildArtifactProvider(CoprArtifactProvider): """ Provider for downloading artifacts from Copr builds. @@ -80,7 +45,6 @@ class CoprBuildArtifactProvider(ArtifactProvider): def __init__(self, raw_id: str, repository_priority: int, logger: tmt.log.Logger): super().__init__(raw_id, repository_priority, logger) - self._session = self._initialize_session() try: build_id_str, chroot = self.id.split(":", 1) self.build_id = int(build_id_str) @@ -97,28 +61,22 @@ def build_info(self) -> Optional["Munch"]: """ return self._session.build_proxy.get(self.build_id) + @property + def _copr_owner(self) -> str: + assert self.build_info is not None + return str(self.build_info.ownername) + + @property + def _copr_project(self) -> str: + assert self.build_info is not None + return str(self.build_info.projectname) + @cached_property def is_pulp(self) -> bool: """ Check if the build is stored in Pulp. """ - assert self.build_info is not None - project = self._session.project_proxy.get( - self.build_info.ownername, self.build_info.projectname - ) - return project is not None and project.storage == "pulp" - - def _initialize_session(self) -> 'Client': - """ - Initialize copr client session. - """ - import_copr(self.logger) - - try: - config = {"copr_url": "https://copr.fedorainfracloud.org"} - return Client(config) - except Exception as error: - raise tmt.utils.GeneralError("Failed to initialize Copr client session.") from error + return self.project_info is not None and self.project_info.storage == "pulp" @classmethod def _extract_provider_id(cls, raw_id: str) -> ArtifactProviderId: From c93f84f90908602bf474bb8a8f24a6f7fb8a90b7 Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Mon, 16 Mar 2026 10:51:01 +0000 Subject: [PATCH 24/31] refactor: move COPR utilities to the sharred module --- tmt/steps/prepare/artifact/providers/_copr.py | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/tmt/steps/prepare/artifact/providers/_copr.py b/tmt/steps/prepare/artifact/providers/_copr.py index 651bc3e206..e807fea12d 100644 --- a/tmt/steps/prepare/artifact/providers/_copr.py +++ b/tmt/steps/prepare/artifact/providers/_copr.py @@ -1,7 +1,8 @@ """ -Shared COPR utilities for copr-based artifact providers. +Shared COPR utilities. """ +import re import types from abc import abstractmethod from functools import cached_property @@ -32,6 +33,33 @@ ) +COPR_URL = 'https://copr.fedorainfracloud.org/coprs' +COPR_REPO_PATTERN = re.compile(r'^(@)?([^/]+)/([^/]+)$') + + +def parse_copr_repo(copr_repo: str) -> tuple[bool, str, str]: + """ + Parse a COPR repository identifier into its components. + """ + matched = COPR_REPO_PATTERN.match(copr_repo) + if not matched: + raise tmt.utils.PrepareError(f"Invalid copr repository '{copr_repo}'.") + is_group, name, project = matched.groups() + return bool(is_group), name, project + + +def build_copr_repo_url(copr_repo: str, chroot: str) -> str: + """ + Construct the URL for a COPR ``.repo`` file. + """ + is_group, name, project = parse_copr_repo(copr_repo) + group = 'group_' if is_group else '' + parts = [COPR_URL] + (['g'] if is_group else []) + parts += [name, project, 'repo', chroot] + parts += [f"{group}{name}-{project}-{chroot}.repo"] + return '/'.join(parts) + + def import_copr(logger: tmt.log.Logger) -> None: """Import copr module with error handling.""" global copr, Client From bfdb94376b463b993b180d04f262d71b03a8923a Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Fri, 20 Mar 2026 11:49:22 +0000 Subject: [PATCH 25/31] more rebasing... --- tmt/steps/prepare/artifact/providers/_copr.py | 120 ------------------ .../prepare/artifact/providers/copr_build.py | 70 ++++++++-- 2 files changed, 56 insertions(+), 134 deletions(-) delete mode 100644 tmt/steps/prepare/artifact/providers/_copr.py diff --git a/tmt/steps/prepare/artifact/providers/_copr.py b/tmt/steps/prepare/artifact/providers/_copr.py deleted file mode 100644 index e807fea12d..0000000000 --- a/tmt/steps/prepare/artifact/providers/_copr.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -Shared COPR utilities. -""" - -import re -import types -from abc import abstractmethod -from functools import cached_property -from typing import Any, Optional - -import tmt.log -import tmt.utils -import tmt.utils.hints -from tmt.steps.prepare.artifact.providers import ArtifactProvider - -copr: Optional[types.ModuleType] = None - -# To silence mypy -Client: Any - -tmt.utils.hints.register_hint( - 'artifact-provider/copr', - """ -The ``copr`` Python package is required by tmt for Copr integration. - -To quickly test Copr presence, you can try running: - - python -c 'import copr' - -* Users who installed tmt from PyPI should install the ``copr`` package - via ``pip install copr``. -""", -) - - -COPR_URL = 'https://copr.fedorainfracloud.org/coprs' -COPR_REPO_PATTERN = re.compile(r'^(@)?([^/]+)/([^/]+)$') - - -def parse_copr_repo(copr_repo: str) -> tuple[bool, str, str]: - """ - Parse a COPR repository identifier into its components. - """ - matched = COPR_REPO_PATTERN.match(copr_repo) - if not matched: - raise tmt.utils.PrepareError(f"Invalid copr repository '{copr_repo}'.") - is_group, name, project = matched.groups() - return bool(is_group), name, project - - -def build_copr_repo_url(copr_repo: str, chroot: str) -> str: - """ - Construct the URL for a COPR ``.repo`` file. - """ - is_group, name, project = parse_copr_repo(copr_repo) - group = 'group_' if is_group else '' - parts = [COPR_URL] + (['g'] if is_group else []) - parts += [name, project, 'repo', chroot] - parts += [f"{group}{name}-{project}-{chroot}.repo"] - return '/'.join(parts) - - -def import_copr(logger: tmt.log.Logger) -> None: - """Import copr module with error handling.""" - global copr, Client - try: - import copr - from copr.v3 import Client - except ImportError as error: - from tmt.utils.hints import print_hints - - print_hints('artifact-provider/copr', logger=logger) - - raise tmt.utils.GeneralError("Could not import copr package.") from error - - -class CoprArtifactProvider(ArtifactProvider): - """ - Base class for COPR-based artifact providers. - """ - - def __init__(self, raw_id: str, repository_priority: int, logger: tmt.log.Logger) -> None: - super().__init__(raw_id, repository_priority, logger) - self._session = self._initialize_session() - - def _initialize_session(self) -> 'Client': - """ - Initialize copr client session. - """ - import_copr(self.logger) - - try: - config = {"copr_url": "https://copr.fedorainfracloud.org"} - return Client(config) - except Exception as error: - raise tmt.utils.GeneralError("Failed to initialize Copr client session.") from error - - @property - @abstractmethod - def _copr_owner(self) -> str: - raise NotImplementedError - - @property - @abstractmethod - def _copr_project(self) -> str: - raise NotImplementedError - - @cached_property - def project_info(self) -> Any: - """ - Fetch and return the COPR project metadata. - """ - try: - return self._session.project_proxy.get( - ownername=self._copr_owner, projectname=self._copr_project - ) - except Exception as error: - raise tmt.utils.GeneralError( - f"Failed to fetch COPR project info for '{self._copr_owner}/{self._copr_project}'." - ) from error diff --git a/tmt/steps/prepare/artifact/providers/copr_build.py b/tmt/steps/prepare/artifact/providers/copr_build.py index 8efea05812..a8e28a095e 100644 --- a/tmt/steps/prepare/artifact/providers/copr_build.py +++ b/tmt/steps/prepare/artifact/providers/copr_build.py @@ -2,31 +2,66 @@ Copr Build Artifact Provider """ +import types from collections.abc import Sequence from functools import cached_property from shlex import quote -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Any, Optional from urllib.parse import urljoin import tmt.log import tmt.utils +import tmt.utils.hints from tmt.guest import Guest from tmt.package_managers._rpm import RpmVersion from tmt.steps.prepare.artifact.providers import ( ArtifactInfo, + ArtifactProvider, ArtifactProviderId, DownloadError, provides_artifact_provider, ) -from tmt.steps.prepare.artifact.providers._copr import CoprArtifactProvider from tmt.utils import ShellScript if TYPE_CHECKING: from munch import Munch +copr: Optional[types.ModuleType] = None + +# To silence mypy +Client: Any + +tmt.utils.hints.register_hint( + 'artifact-provider/copr', + """ +The ``copr`` Python package is required by tmt for Copr integration. + +To quickly test Copr presence, you can try running: + + python -c 'import copr' + +* Users who installed tmt from PyPI should install the ``copr`` package + via ``pip install copr``. +""", +) + + +def import_copr(logger: tmt.log.Logger) -> None: + """Import copr module with error handling.""" + global copr, Client + try: + import copr + from copr.v3 import Client + except ImportError as error: + from tmt.utils.hints import print_hints + + print_hints('artifact-provider/copr', logger=logger) + + raise tmt.utils.GeneralError("Could not import copr package.") from error + @provides_artifact_provider("copr.build") -class CoprBuildArtifactProvider(CoprArtifactProvider): +class CoprBuildArtifactProvider(ArtifactProvider): """ Provider for downloading artifacts from Copr builds. @@ -45,6 +80,7 @@ class CoprBuildArtifactProvider(CoprArtifactProvider): def __init__(self, raw_id: str, repository_priority: int, logger: tmt.log.Logger): super().__init__(raw_id, repository_priority, logger) + self._session = self._initialize_session() try: build_id_str, chroot = self.id.split(":", 1) self.build_id = int(build_id_str) @@ -61,22 +97,28 @@ def build_info(self) -> Optional["Munch"]: """ return self._session.build_proxy.get(self.build_id) - @property - def _copr_owner(self) -> str: - assert self.build_info is not None - return str(self.build_info.ownername) - - @property - def _copr_project(self) -> str: - assert self.build_info is not None - return str(self.build_info.projectname) - @cached_property def is_pulp(self) -> bool: """ Check if the build is stored in Pulp. """ - return self.project_info is not None and self.project_info.storage == "pulp" + assert self.build_info is not None + project = self._session.project_proxy.get( + self.build_info.ownername, self.build_info.projectname + ) + return project is not None and project.storage == "pulp" + + def _initialize_session(self) -> 'Client': + """ + Initialize copr client session. + """ + import_copr(self.logger) + + try: + config = {"copr_url": "https://copr.fedorainfracloud.org"} + return Client(config) + except Exception as error: + raise tmt.utils.GeneralError("Failed to initialize Copr client session.") from error @classmethod def _extract_provider_id(cls, raw_id: str) -> ArtifactProviderId: From 0926e2bb6beaae95d1e63b634bdec515d2a50952 Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Tue, 24 Mar 2026 12:46:45 +0000 Subject: [PATCH 26/31] address comments... --- tmt/steps/prepare/artifact/providers/copr_repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tmt/steps/prepare/artifact/providers/copr_repository.py b/tmt/steps/prepare/artifact/providers/copr_repository.py index 6cb047bbde..b98c0ca8a1 100644 --- a/tmt/steps/prepare/artifact/providers/copr_repository.py +++ b/tmt/steps/prepare/artifact/providers/copr_repository.py @@ -79,7 +79,7 @@ def _extract_provider_id(cls, raw_id: str) -> ArtifactProviderId: if not raw_id.startswith(prefix): raise ValueError(f"Invalid Copr repository provider format: '{raw_id}'.") - value = raw_id[len(prefix):] + value = raw_id[len(prefix) :] if not value: raise ValueError("Missing Copr repository name.") From b0b46fff338cd80c64dfe41e25b3295929073162 Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Mon, 16 Mar 2026 16:32:25 +0000 Subject: [PATCH 27/31] persist repository metadata in artifacts.yaml --- tmt/steps/prepare/artifact/providers/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tmt/steps/prepare/artifact/providers/__init__.py b/tmt/steps/prepare/artifact/providers/__init__.py index 07335bd048..51c3a350eb 100644 --- a/tmt/steps/prepare/artifact/providers/__init__.py +++ b/tmt/steps/prepare/artifact/providers/__init__.py @@ -110,7 +110,7 @@ def __init__(self, raw_id: str, repository_priority: int, logger: tmt.log.Logger self.sanitized_id = tmt.utils.sanitize_name(raw_id, allow_slash=False) self.id = self._extract_provider_id(raw_id) - self._artifacts = [] + self._artifacts: list[ArtifactInfo] = [] @classmethod @abstractmethod From a3508c54ec212bf3719f4b9e9ccd4e1293b0a529 Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Mon, 23 Mar 2026 13:45:51 +0000 Subject: [PATCH 28/31] refactor: return type of list_packages --- tests/unit/artifact/test_copr_repository.py | 6 ++-- tmt/package_managers/__init__.py | 10 ++++-- .../prepare/artifact/providers/__init__.py | 36 +++++++------------ 3 files changed, 23 insertions(+), 29 deletions(-) diff --git a/tests/unit/artifact/test_copr_repository.py b/tests/unit/artifact/test_copr_repository.py index d15cbc5a3e..f479c7e1e1 100644 --- a/tests/unit/artifact/test_copr_repository.py +++ b/tests/unit/artifact/test_copr_repository.py @@ -2,6 +2,8 @@ import pytest +from tmt.steps.prepare.artifact.providers import RpmVersion + @pytest.mark.parametrize( ("raw_id", "expected"), @@ -68,8 +70,8 @@ def test_enumerate_artifacts( mock_guest.package_manager = mock_package_manager mock_package_manager.list_packages.return_value = [ - 'tmt-1.69.0-1.fc42.noarch', - 'tmt-all-0:1.69.0-1.fc42.noarch', + RpmVersion.from_nevra('tmt-1.69.0-1.fc42.noarch'), + RpmVersion.from_nevra('tmt-all-0:1.69.0-1.fc42.noarch'), ] provider = artifact_provider("copr.repository:@teemtee/stable") diff --git a/tmt/package_managers/__init__.py b/tmt/package_managers/__init__.py index 874a608097..6836d5394f 100644 --- a/tmt/package_managers/__init__.py +++ b/tmt/package_managers/__init__.py @@ -16,6 +16,7 @@ if TYPE_CHECKING: from tmt._compat.typing import TypeAlias from tmt.guest import Guest + from tmt.package_managers._rpm import RpmVersion #: A type of package manager names. GuestPackageManager: TypeAlias = str @@ -499,13 +500,15 @@ def install_repository(self, repository: "Repository") -> CommandOutput: """ return self.guest.execute(self.engine.install_repository(repository)) - def list_packages(self, repository: "Repository") -> list[str]: + def list_packages(self, repository: "Repository") -> "list[RpmVersion]": """ List packages available in the specified repository. :param repository: The repository to query. - :returns: A list of package names available in the repository. + :returns: A list of RPM versions available in the repository. """ + from tmt.package_managers._rpm import RpmVersion + script = self.engine.list_packages(repository) output = self.guest.execute(script) stdout = output.stdout @@ -513,7 +516,8 @@ def list_packages(self, repository: "Repository") -> list[str]: if stdout is None: raise GeneralError("Repository query provided no output") - return stdout.strip().splitlines() + stripped_lines = (line.strip() for line in stdout.strip().splitlines()) + return [RpmVersion.from_nevra(line) for line in stripped_lines if line] def get_package_origin(self, packages: Iterable[str]) -> 'dict[str, PackageOrigin]': """ diff --git a/tmt/steps/prepare/artifact/providers/__init__.py b/tmt/steps/prepare/artifact/providers/__init__.py index 51c3a350eb..47819e9b54 100644 --- a/tmt/steps/prepare/artifact/providers/__init__.py +++ b/tmt/steps/prepare/artifact/providers/__init__.py @@ -229,9 +229,7 @@ def get_repositories(self) -> list['Repository']: """ return [] - def enumerate_artifacts( - self, guest: Guest - ) -> None: # TODO: refactor this once the NEVRA parsing is centralized. + def enumerate_artifacts(self, guest: Guest) -> None: """ Enumerate artifacts from repositories returned by :py:meth:`get_repositories` and populate :py:attr:`_artifacts`. Call this after repositories are installed. @@ -241,7 +239,7 @@ def enumerate_artifacts( """ for repository in self.get_repositories(): try: - nevras = guest.package_manager.list_packages(repository) + packages = guest.package_manager.list_packages(repository) except tmt.utils.RunError as error: tmt.utils.show_exception_as_warning( exception=error, @@ -249,27 +247,17 @@ def enumerate_artifacts( logger=self.logger, ) continue - count = 0 - for nevra in nevras: - nevra = nevra.strip() - if not nevra: - continue - try: - self._artifacts.append( - ArtifactInfo( - version=RpmVersion.from_nevra(nevra), - provider=self, - location=repository.name, - ) + for rpm_version in packages: + self._artifacts.append( + ArtifactInfo( + version=rpm_version, + provider=self, + location=repository.name, ) - count += 1 - except ValueError as error: - tmt.utils.show_exception_as_warning( - exception=error, - message=f"Could not parse NEVRA '{nevra}'.", - logger=self.logger, - ) - self.logger.debug(f"Enumerated {count} packages from repository '{repository.name}'.") + ) + self.logger.debug( + f"Enumerated {len(packages)} packages from repository '{repository.name}'." + ) # B027: "... is an empty method in an abstract base class, but has # no abstract decorator" - expected, it's a default implementation From d9db859975d51d3738374f1e221f5ab8d844f695 Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Mon, 30 Mar 2026 22:16:37 +0100 Subject: [PATCH 29/31] refactor: return type of list_packages --- tests/unit/artifact/test_copr_repository.py | 2 +- tmt/package_managers/__init__.py | 5 ++--- tmt/steps/prepare/artifact/providers/copr_repository.py | 5 ----- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/tests/unit/artifact/test_copr_repository.py b/tests/unit/artifact/test_copr_repository.py index f479c7e1e1..aaedabbcf0 100644 --- a/tests/unit/artifact/test_copr_repository.py +++ b/tests/unit/artifact/test_copr_repository.py @@ -2,7 +2,7 @@ import pytest -from tmt.steps.prepare.artifact.providers import RpmVersion +from tmt.package_managers._rpm import RpmVersion @pytest.mark.parametrize( diff --git a/tmt/package_managers/__init__.py b/tmt/package_managers/__init__.py index 6836d5394f..82a64125c6 100644 --- a/tmt/package_managers/__init__.py +++ b/tmt/package_managers/__init__.py @@ -16,7 +16,6 @@ if TYPE_CHECKING: from tmt._compat.typing import TypeAlias from tmt.guest import Guest - from tmt.package_managers._rpm import RpmVersion #: A type of package manager names. GuestPackageManager: TypeAlias = str @@ -500,12 +499,12 @@ def install_repository(self, repository: "Repository") -> CommandOutput: """ return self.guest.execute(self.engine.install_repository(repository)) - def list_packages(self, repository: "Repository") -> "list[RpmVersion]": + def list_packages(self, repository: "Repository") -> list[Version]: """ List packages available in the specified repository. :param repository: The repository to query. - :returns: A list of RPM versions available in the repository. + :returns: A list of versions available in the repository. """ from tmt.package_managers._rpm import RpmVersion diff --git a/tmt/steps/prepare/artifact/providers/copr_repository.py b/tmt/steps/prepare/artifact/providers/copr_repository.py index b98c0ca8a1..f763205c9e 100644 --- a/tmt/steps/prepare/artifact/providers/copr_repository.py +++ b/tmt/steps/prepare/artifact/providers/copr_repository.py @@ -96,11 +96,6 @@ def get_repositories(self) -> list[Repository]: return [] return [self.repository] - def get_repositories(self) -> list[Repository]: - if self.repository is None: - return [] - return [self.repository] - def _download_artifact(self, artifact: ArtifactInfo, guest: Guest, destination: Path) -> None: """This provider only enables repositories; it does not download individual RPMs.""" raise UnsupportedOperationError( From cbeec45b56fb9af88b9197b34bffa9e17b2d2f18 Mon Sep 17 00:00:00 2001 From: Athrey Vinay Date: Tue, 31 Mar 2026 09:49:58 +0100 Subject: [PATCH 30/31] address comments... --- tmt/package_managers/__init__.py | 12 +----------- tmt/package_managers/dnf.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/tmt/package_managers/__init__.py b/tmt/package_managers/__init__.py index 82a64125c6..2ef2ca8f44 100644 --- a/tmt/package_managers/__init__.py +++ b/tmt/package_managers/__init__.py @@ -506,17 +506,7 @@ def list_packages(self, repository: "Repository") -> list[Version]: :param repository: The repository to query. :returns: A list of versions available in the repository. """ - from tmt.package_managers._rpm import RpmVersion - - script = self.engine.list_packages(repository) - output = self.guest.execute(script) - stdout = output.stdout - - if stdout is None: - raise GeneralError("Repository query provided no output") - - stripped_lines = (line.strip() for line in stdout.strip().splitlines()) - return [RpmVersion.from_nevra(line) for line in stripped_lines if line] + raise NotImplementedError def get_package_origin(self, packages: Iterable[str]) -> 'dict[str, PackageOrigin]': """ diff --git a/tmt/package_managers/dnf.py b/tmt/package_managers/dnf.py index 436ea8d58d..a426ee172c 100644 --- a/tmt/package_managers/dnf.py +++ b/tmt/package_managers/dnf.py @@ -11,6 +11,7 @@ PackageManager, PackageManagerEngine, Repository, + Version, escape_installables, provides_package_manager, ) @@ -242,6 +243,19 @@ class Dnf(PackageManager[DnfEngine]): # the `dnf` family just stays below it. probe_priority = 50 + def list_packages(self, repository: Repository) -> list[Version]: + from tmt.package_managers._rpm import RpmVersion + + script = self.engine.list_packages(repository) + output = self.guest.execute(script) + stdout = output.stdout + + if stdout is None: + raise GeneralError("Repository query provided no output") + + stripped_lines = (line.strip() for line in stdout.strip().splitlines()) + return [RpmVersion.from_nevra(line) for line in stripped_lines if line] + def check_presence(self, *installables: Installable) -> dict[Installable, bool]: try: output = self.guest.execute(self.engine.check_presence(*installables)) From 129205daec5baa416f7f18df910e473efe77a3d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20=C5=A0pl=C3=ADchal?= Date: Wed, 1 Apr 2026 14:22:02 +0200 Subject: [PATCH 31/31] Drop unused import --- tmt/steps/prepare/artifact/providers/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tmt/steps/prepare/artifact/providers/__init__.py b/tmt/steps/prepare/artifact/providers/__init__.py index 47819e9b54..bc81b0db1f 100644 --- a/tmt/steps/prepare/artifact/providers/__init__.py +++ b/tmt/steps/prepare/artifact/providers/__init__.py @@ -15,7 +15,6 @@ from tmt.container import container from tmt.guest import Guest from tmt.package_managers import Repository, Version -from tmt.package_managers._rpm import RpmVersion from tmt.plugins import PluginRegistry from tmt.utils import Path, ShellScript