From fe7bf3e43a557d71bcccd414512c4aefb6a43758 Mon Sep 17 00:00:00 2001 From: Tomas Bajer Date: Fri, 12 Dec 2025 18:41:21 +0100 Subject: [PATCH 1/4] Improve logging of requires --- tmt/steps/discover/__init__.py | 17 ++++++++++ tmt/steps/prepare/__init__.py | 59 +++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/tmt/steps/discover/__init__.py b/tmt/steps/discover/__init__.py index 7f1bab1071..1f7a0cf49d 100644 --- a/tmt/steps/discover/__init__.py +++ b/tmt/steps/discover/__init__.py @@ -280,6 +280,23 @@ def required_tests(self) -> list[TestOrigin]: ] return tests + @property + def dependencies_to_tests(self) -> dict[str, list[str]]: + """ + Dictionary mapping dependencies to tests. + """ + + dependencies_to_tests: dict[str, list[str]] = {} + + for test_origin in self.tests(enabled=True): + test = test_origin.test + test_name = test.name + # Collect all dependencies (required + recommended) + for dependency in [*test.require, *test.recommend]: + dependencies_to_tests.setdefault(str(dependency), []).append(test_name) + + return dependencies_to_tests + def load(self) -> None: """ Load step data from the workdir diff --git a/tmt/steps/prepare/__init__.py b/tmt/steps/prepare/__init__.py index f9714e1908..6f73b58405 100644 --- a/tmt/steps/prepare/__init__.py +++ b/tmt/steps/prepare/__init__.py @@ -221,6 +221,61 @@ def summary(self) -> None: preparations = fmf.utils.listed(self.preparations_applied, 'preparation') self.info('summary', f'{preparations} applied', 'green', shift=1) + def _extract_failed_packages(self, exceptions: list[Exception]) -> set[str]: + """ + Extract package names from installation exceptions. + + Returns a list of package names that failed to install. + """ + + failed_packages: set[str] = set() + import re + + for exc in exceptions: + # Extract failed packages from RunError stderr/stdout + if isinstance(exc, tmt.utils.RunError): + error_text = '' + if exc.stderr: + error_text += exc.stderr + '\n' + if exc.stdout: + error_text += exc.stdout + + if error_text: + # dnf, dnf5, yum, apt, apk specific patterns + patterns = [ + r'no package provides\s+([^\s\n]+)', + r'Unable to find a match:\s+([^\s\n]+)', + r'No match for argument:\s+([^\s\n]+)', + r'No package\s+([^\s\n]+)\s+available', + r'Unable to locate package\s+([^\s]+)', + r'E:\s+Unable to locate package\s+([^\s]+)', + ] + + for pattern in patterns: + matches = re.findall(pattern, error_text, re.IGNORECASE) + failed_packages.update(matches) + + return failed_packages + + def _show_failed_packages_with_tests(self, failed_packages: set[str]) -> None: + """ + Show failed packages and which tests require them. + """ + + # Get test dependencies from discover step + dependencies_to_tests = self.plan.discover.dependencies_to_tests + + # Show failed packages with their associated tests + self.info('') + self.info('failed packages', '', 'red', shift=1) + + for failed_package in failed_packages: + matching_tests = [] + if failed_package in dependencies_to_tests: + matching_tests = dependencies_to_tests[failed_package] + sorted_matching_tests = ', '.join(sorted(matching_tests)) + self.info(failed_package, f'required by {sorted_matching_tests}', color='red', shift=2) + def go(self, force: bool = False) -> None: """ Prepare the guests @@ -498,7 +553,9 @@ def _record_exception( self._save_results(self.results) if exceptions: - # TODO: needs a better message... + failed_packages = self._extract_failed_packages(exceptions) + if failed_packages: + self._show_failed_packages_with_tests(failed_packages) raise tmt.utils.PrepareError( 'prepare step failed', causes=exceptions, From b46f5c7e187e80867f43bad77ee3d0db98e88c9f Mon Sep 17 00:00:00 2001 From: Tomas Bajer Date: Fri, 9 Jan 2026 22:29:05 +0100 Subject: [PATCH 2/4] Used command output instead of exceptions --- tmt/package_managers/__init__.py | 10 +++ tmt/package_managers/apk.py | 14 ++++ tmt/package_managers/apt.py | 16 ++++ tmt/package_managers/dnf.py | 21 +++++ tmt/steps/discover/__init__.py | 17 ++-- tmt/steps/prepare/__init__.py | 59 +------------- tmt/steps/prepare/install.py | 132 ++++++++++++++++++++++++++++--- 7 files changed, 193 insertions(+), 76 deletions(-) diff --git a/tmt/package_managers/__init__.py b/tmt/package_managers/__init__.py index 23046984d8..b651fe21c9 100644 --- a/tmt/package_managers/__init__.py +++ b/tmt/package_managers/__init__.py @@ -270,6 +270,16 @@ def check_presence(self, *installables: Installable) -> dict[Installable, bool]: raise NotImplementedError + def extract_package_name_from_package_manager_output(self, output: str) -> Iterator[str]: + """ + Extract failed package names from package manager error output. + + :param output: Error output (stdout or stderr) from the package manager. + :returns: An iterator of package names that failed to install. + """ + + raise NotImplementedError + def install( self, *installables: Installable, diff --git a/tmt/package_managers/apk.py b/tmt/package_managers/apk.py index d97c11ecbd..597926961b 100644 --- a/tmt/package_managers/apk.py +++ b/tmt/package_managers/apk.py @@ -1,4 +1,5 @@ import re +from collections.abc import Iterator from typing import Optional, Union import tmt.utils @@ -34,6 +35,11 @@ } +# Compiled regex patterns for APK error messages +_UNABLE_TO_LOCATE_PATTERN = re.compile(r'unable to locate package\s+([^\s]+)', re.IGNORECASE) +_NO_SUCH_PACKAGE_PATTERN = re.compile(r'ERROR:\s+([^\s:]+):\s+No such package', re.IGNORECASE) + + class ApkEngine(PackageManagerEngine): install_command = Command('add') @@ -189,3 +195,11 @@ def install_debuginfo( options: Optional[Options] = None, ) -> CommandOutput: raise tmt.utils.GeneralError("There is no support for debuginfo packages in apk.") + + def extract_package_name_from_package_manager_output(self, output: str) -> Iterator[str]: + """ + Extract failed package names from APK error output. + """ + for pattern in [_UNABLE_TO_LOCATE_PATTERN, _NO_SUCH_PACKAGE_PATTERN]: + for match in pattern.finditer(output): + yield match.group(1) diff --git a/tmt/package_managers/apt.py b/tmt/package_managers/apt.py index 02d139a817..d58f2dd53a 100644 --- a/tmt/package_managers/apt.py +++ b/tmt/package_managers/apt.py @@ -1,4 +1,5 @@ import re +from collections.abc import Iterator from typing import Optional, Union import tmt.utils @@ -79,6 +80,13 @@ """ # noqa: E501 +# Compiled regex patterns for APT error messages +_UNABLE_TO_LOCATE_PATTERN = re.compile(r'Unable to locate package\s+([^\s]+)', re.IGNORECASE) +_E_UNABLE_TO_LOCATE_PATTERN = re.compile( + r'E:\s+Unable to locate package\s+([^\s]+)', re.IGNORECASE +) + + class AptEngine(PackageManagerEngine): install_command = Command('install') @@ -281,3 +289,11 @@ def install_debuginfo( options: Optional[Options] = None, ) -> CommandOutput: raise tmt.utils.GeneralError("There is no support for debuginfo packages in apt.") + + def extract_package_name_from_package_manager_output(self, output: str) -> Iterator[str]: + """ + Extract failed package names from APT error output. + """ + for pattern in [_UNABLE_TO_LOCATE_PATTERN, _E_UNABLE_TO_LOCATE_PATTERN]: + for match in pattern.finditer(output): + yield match.group(1) diff --git a/tmt/package_managers/dnf.py b/tmt/package_managers/dnf.py index 4e75a67d3b..93b135002b 100644 --- a/tmt/package_managers/dnf.py +++ b/tmt/package_managers/dnf.py @@ -1,4 +1,5 @@ import re +from collections.abc import Iterator from typing import TYPE_CHECKING, Any, Optional, cast from tmt._compat.pathlib import Path @@ -201,6 +202,13 @@ def create_repository(self, directory: Path) -> ShellScript: return ShellScript(f"createrepo {directory}") +# Compiled regex patterns for DNF/YUM error messages +_NO_PACKAGE_PROVIDES_PATTERN = re.compile(r'no package provides\s+([^\s\n]+)', re.IGNORECASE) +_UNABLE_TO_FIND_MATCH_PATTERN = re.compile(r'Unable to find a match:\s+([^\s\n]+)', re.IGNORECASE) +_NO_MATCH_FOR_ARGUMENT_PATTERN = re.compile(r'No match for argument:\s+([^\s\n]+)', re.IGNORECASE) +_NO_PACKAGE_AVAILABLE_PATTERN = re.compile(r'No package\s+([^\s\n]+)\s+available', re.IGNORECASE) + + # ignore[type-arg]: TypeVar in package manager registry annotations is # puzzling for type checkers. And not a good idea in general, probably. @provides_package_manager('dnf') # type: ignore[arg-type] @@ -260,6 +268,19 @@ def check_presence(self, *installables: Installable) -> dict[Installable, bool]: return results + def extract_package_name_from_package_manager_output(self, output: str) -> Iterator[str]: + """ + Extract failed package names from DNF/YUM error output. + """ + for pattern in [ + _NO_PACKAGE_PROVIDES_PATTERN, + _UNABLE_TO_FIND_MATCH_PATTERN, + _NO_MATCH_FOR_ARGUMENT_PATTERN, + _NO_PACKAGE_AVAILABLE_PATTERN, + ]: + for match in pattern.finditer(output): + yield match.group(1) + class Dnf5Engine(DnfEngine): _base_command = Command('dnf5') diff --git a/tmt/steps/discover/__init__.py b/tmt/steps/discover/__init__.py index 1f7a0cf49d..a0a3c57686 100644 --- a/tmt/steps/discover/__init__.py +++ b/tmt/steps/discover/__init__.py @@ -281,21 +281,24 @@ def required_tests(self) -> list[TestOrigin]: return tests @property - def dependencies_to_tests(self) -> dict[str, list[str]]: + def dependencies_to_tests(self) -> tuple[dict[str, list[str]], dict[str, list[str]]]: """ - Dictionary mapping dependencies to tests. + A tuple containing two dictionaries mapping dependencies to tests (required & recommended) """ - dependencies_to_tests: dict[str, list[str]] = {} + required_dependencies_to_tests: dict[str, list[str]] = {} + recommended_dependencies_to_tests: dict[str, list[str]] = {} for test_origin in self.tests(enabled=True): test = test_origin.test test_name = test.name - # Collect all dependencies (required + recommended) - for dependency in [*test.require, *test.recommend]: - dependencies_to_tests.setdefault(str(dependency), []).append(test_name) + # Collect dependencies separately for required and recommended + for dependency in test.require: + required_dependencies_to_tests.setdefault(str(dependency), []).append(test_name) + for dependency in test.recommend: + recommended_dependencies_to_tests.setdefault(str(dependency), []).append(test_name) - return dependencies_to_tests + return required_dependencies_to_tests, recommended_dependencies_to_tests def load(self) -> None: """ diff --git a/tmt/steps/prepare/__init__.py b/tmt/steps/prepare/__init__.py index 6f73b58405..f9714e1908 100644 --- a/tmt/steps/prepare/__init__.py +++ b/tmt/steps/prepare/__init__.py @@ -221,61 +221,6 @@ def summary(self) -> None: preparations = fmf.utils.listed(self.preparations_applied, 'preparation') self.info('summary', f'{preparations} applied', 'green', shift=1) - def _extract_failed_packages(self, exceptions: list[Exception]) -> set[str]: - """ - Extract package names from installation exceptions. - - Returns a list of package names that failed to install. - """ - - failed_packages: set[str] = set() - import re - - for exc in exceptions: - # Extract failed packages from RunError stderr/stdout - if isinstance(exc, tmt.utils.RunError): - error_text = '' - if exc.stderr: - error_text += exc.stderr + '\n' - if exc.stdout: - error_text += exc.stdout - - if error_text: - # dnf, dnf5, yum, apt, apk specific patterns - patterns = [ - r'no package provides\s+([^\s\n]+)', - r'Unable to find a match:\s+([^\s\n]+)', - r'No match for argument:\s+([^\s\n]+)', - r'No package\s+([^\s\n]+)\s+available', - r'Unable to locate package\s+([^\s]+)', - r'E:\s+Unable to locate package\s+([^\s]+)', - ] - - for pattern in patterns: - matches = re.findall(pattern, error_text, re.IGNORECASE) - failed_packages.update(matches) - - return failed_packages - - def _show_failed_packages_with_tests(self, failed_packages: set[str]) -> None: - """ - Show failed packages and which tests require them. - """ - - # Get test dependencies from discover step - dependencies_to_tests = self.plan.discover.dependencies_to_tests - - # Show failed packages with their associated tests - self.info('') - self.info('failed packages', '', 'red', shift=1) - - for failed_package in failed_packages: - matching_tests = [] - if failed_package in dependencies_to_tests: - matching_tests = dependencies_to_tests[failed_package] - sorted_matching_tests = ', '.join(sorted(matching_tests)) - self.info(failed_package, f'required by {sorted_matching_tests}', color='red', shift=2) - def go(self, force: bool = False) -> None: """ Prepare the guests @@ -553,9 +498,7 @@ def _record_exception( self._save_results(self.results) if exceptions: - failed_packages = self._extract_failed_packages(exceptions) - if failed_packages: - self._show_failed_packages_with_tests(failed_packages) + # TODO: needs a better message... raise tmt.utils.PrepareError( 'prepare step failed', causes=exceptions, diff --git a/tmt/steps/prepare/install.py b/tmt/steps/prepare/install.py index 39c77f86d7..593120fd86 100644 --- a/tmt/steps/prepare/install.py +++ b/tmt/steps/prepare/install.py @@ -50,6 +50,8 @@ class InstallBase(tmt.utils.Common): package_directory: Path + install_outputs: list[tmt.utils.CommandOutput] + def __init__( self, *, @@ -74,6 +76,7 @@ def __init__( self.exclude = [Package(package) for package in exclude] self.skip_missing = bool(parent.get('missing') == 'skip') + self.install_outputs = [] # Prepare package lists and installation command self.prepare_installables(dependencies, directories) @@ -345,26 +348,28 @@ def install_from_url(self) -> None: Install packages stored on a remote URL """ - self.guest.package_manager.install( + output = self.guest.package_manager.install( *self.list_installables("remote package", *self.remote_packages), options=Options( excluded_packages=self.exclude, skip_missing=self.skip_missing, ), ) + self.install_outputs.append(output) def install_from_repository(self) -> None: """ Install packages from a repository """ - self.guest.package_manager.install( + output = self.guest.package_manager.install( *self.list_installables("package", *self.packages), options=Options( excluded_packages=self.exclude, skip_missing=self.skip_missing, ), ) + self.install_outputs.append(output) def install_debuginfo(self) -> None: """ @@ -517,12 +522,13 @@ def install_local(self) -> None: PackagePath(self.package_directory / filename) for filename in self.local_packages ] - self.guest.package_manager.install( + output = self.guest.package_manager.install( *self.list_installables('local packages', *filelist), options=Options( excluded_packages=self.exclude, skip_missing=self.skip_missing, check_first=False ), ) + self.install_outputs.append(output) summary = fmf.utils.listed([str(path) for path in self.local_packages], 'local package') self.info('total', f"{summary} installed", 'green') @@ -532,26 +538,28 @@ def install_from_url(self) -> None: Install packages stored on a remote URL """ - self.guest.package_manager.install( + output = self.guest.package_manager.install( *self.list_installables("remote package", *self.remote_packages), options=Options( excluded_packages=self.exclude, skip_missing=self.skip_missing, ), ) + self.install_outputs.append(output) def install_from_repository(self) -> None: """ Install packages from a repository """ - self.guest.package_manager.install( + output = self.guest.package_manager.install( *self.list_installables("package", *self.packages), options=Options( excluded_packages=self.exclude, skip_missing=self.skip_missing, ), ) + self.install_outputs.append(output) def install_debuginfo(self) -> None: """ @@ -687,13 +695,14 @@ def _install(self) -> None: class InstallMock(InstallBase): # TODO this really looks like it should be a subclass of InstallDnf def install_from_repository(self) -> None: - self.guest.package_manager.install( + output = self.guest.package_manager.install( *self.list_installables("package", *self.packages), options=Options( excluded_packages=self.exclude, skip_missing=self.skip_missing, ), ) + self.install_outputs.append(output) def install_local(self) -> None: from tmt.steps.provision.mock import GuestMock @@ -714,7 +723,7 @@ def install_local(self) -> None: for filename in self.local_packages ] - self.guest.package_manager.install( + output = self.guest.package_manager.install( *filelist, options=Options( excluded_packages=self.exclude, @@ -722,6 +731,7 @@ def install_local(self) -> None: check_first=False, ), ) + self.install_outputs.append(output) self.guest.package_manager.reinstall( *filelist, @@ -736,13 +746,14 @@ def install_local(self) -> None: self.info('total', f"{summary} installed", 'green') def install_from_url(self) -> None: - self.guest.package_manager.install( + output = self.guest.package_manager.install( *self.list_installables("remote package", *self.remote_packages), options=Options( excluded_packages=self.exclude, skip_missing=self.skip_missing, ), ) + self.install_outputs.append(output) class InstallApk(InstallBase): @@ -759,7 +770,7 @@ def install_local(self) -> None: PackagePath(self.package_directory / filename) for filename in self.local_packages ] - self.guest.package_manager.install( + output = self.guest.package_manager.install( *self.list_installables('local packages', *filelist), options=Options( excluded_packages=self.exclude, @@ -768,6 +779,7 @@ def install_local(self) -> None: check_first=False, ), ) + self.install_outputs.append(output) summary = fmf.utils.listed([str(path) for path in self.local_packages], 'local package') self.info('total', f"{summary} installed", 'green') @@ -787,13 +799,14 @@ def install_from_repository(self) -> None: Install packages from a repository """ - self.guest.package_manager.install( + output = self.guest.package_manager.install( *self.list_installables("package", *self.packages), options=Options( excluded_packages=self.exclude, skip_missing=self.skip_missing, ), ) + self.install_outputs.append(output) def install_debuginfo(self) -> None: """ @@ -950,6 +963,85 @@ class PrepareInstall(tmt.steps.prepare.PreparePlugin[PrepareInstallData]): _data_class = PrepareInstallData + def _extract_failed_packages_from_outputs( + self, outputs: list[tmt.utils.CommandOutput], guest: 'Guest' + ) -> set[str]: + """ + Extract package names from installation outputs. + + Returns a set of package names that failed to install. + """ + import itertools + + def _extract_from_output(output: tmt.utils.CommandOutput) -> Iterator[str]: + if output.stderr: + yield from guest.package_manager.extract_package_name_from_package_manager_output( + output.stderr + ) + + if output.stdout: + yield from guest.package_manager.extract_package_name_from_package_manager_output( + output.stdout + ) + + return set( + itertools.chain.from_iterable(_extract_from_output(output) for output in outputs) + ) + + def _show_failed_packages_with_tests(self, failed_packages: set[str]) -> None: + """ + Show failed packages and which tests require them. + """ + # Get test dependencies from discover step + required_dependencies_to_tests, recommended_dependencies_to_tests = ( + self.step.plan.discover.dependencies_to_tests + ) + + failed_required_packages: dict[str, list[str]] = {} + failed_recommended_packages: dict[str, list[str]] = {} + failed_unattributed_packages: set[str] = set() + + for failed_package in failed_packages: + if tests := required_dependencies_to_tests.get(failed_package): + failed_required_packages[failed_package] = sorted(tests) + + elif tests := recommended_dependencies_to_tests.get(failed_package): + failed_recommended_packages[failed_package] = sorted(tests) + + else: + failed_unattributed_packages.add(failed_package) + + self.info('') + + if failed_required_packages: + self.info('Required packages failed to install, aborting:', color='red', shift=1) + for pkg, tests in failed_required_packages.items(): + self.info( + pkg, + f'required by: {", ".join(tests)}', + color='red', + shift=2, + ) + + if failed_recommended_packages: + self.info( + 'Recommended packages failed to install, continuing regardless:', + color='yellow', + shift=1, + ) + for pkg, tests in failed_recommended_packages.items(): + self.info( + pkg, + f'recommended by: {", ".join(tests)}', + color='yellow', + shift=2, + ) + + if failed_unattributed_packages: + self.info('Other failed packages:', color='red', shift=1) + for pkg in sorted(failed_unattributed_packages): + self.info(pkg, color='red', shift=2) + def go( self, *, @@ -1067,6 +1159,24 @@ def go( installer.enable_copr(self.data.copr) # ... and install packages. - installer.install() + try: + installer.install() + except Exception as exc: + # Extract and show failed packages if this is a RunError + # Convert exception to CommandOutput and use the unified extraction method + if isinstance(exc, tmt.utils.RunError): + failed_packages = self._extract_failed_packages_from_outputs([exc.output], guest) + if failed_packages: + self._show_failed_packages_with_tests(failed_packages) + raise + + # For recommended packages (skip_missing=True), check output even if no exception + # was raised, since --skip-broken makes the command succeed but packages still fail + if installer.skip_missing: + failed_packages = self._extract_failed_packages_from_outputs( + installer.install_outputs, guest + ) + if failed_packages: + self._show_failed_packages_with_tests(failed_packages) return outcome From c8e94d50d292a5617ec5d7feea591bf1505680f9 Mon Sep 17 00:00:00 2001 From: Tomas Bajer Date: Tue, 13 Jan 2026 19:44:33 +0100 Subject: [PATCH 3/4] Minor improvements from comments --- tmt/package_managers/__init__.py | 10 ++++++++-- tmt/package_managers/apk.py | 11 ++--------- tmt/package_managers/apt.py | 16 ++++------------ tmt/package_managers/dnf.py | 21 +++++++-------------- tmt/steps/discover/__init__.py | 9 +++++---- 5 files changed, 26 insertions(+), 41 deletions(-) diff --git a/tmt/package_managers/__init__.py b/tmt/package_managers/__init__.py index b651fe21c9..0501e10e99 100644 --- a/tmt/package_managers/__init__.py +++ b/tmt/package_managers/__init__.py @@ -1,4 +1,5 @@ import abc +import re import shlex from collections.abc import Iterator from typing import TYPE_CHECKING, Any, Callable, Generic, Optional, TypeVar, Union @@ -240,6 +241,10 @@ class PackageManager(tmt.utils.Common, Generic[PackageManagerEngineT]): _engine_class: type[PackageManagerEngineT] engine: PackageManagerEngineT + #: Patterns for extracting failed package names from error output. + #: Subclasses override this with their own specific patterns. + _PACKAGE_NAME_IN_PM_OUTPUT_PATTERNS: list[re.Pattern[str]] = [] + #: If set, this package manager can be used for building derived #: images under the hood of the ``bootc`` package manager. bootc_builder: bool = False @@ -277,8 +282,9 @@ def extract_package_name_from_package_manager_output(self, output: str) -> Itera :param output: Error output (stdout or stderr) from the package manager. :returns: An iterator of package names that failed to install. """ - - raise NotImplementedError + for pattern in self._PACKAGE_NAME_IN_PM_OUTPUT_PATTERNS: + for match in pattern.finditer(output): + yield match.group(1) def install( self, diff --git a/tmt/package_managers/apk.py b/tmt/package_managers/apk.py index 597926961b..61cb80cf82 100644 --- a/tmt/package_managers/apk.py +++ b/tmt/package_managers/apk.py @@ -1,5 +1,4 @@ import re -from collections.abc import Iterator from typing import Optional, Union import tmt.utils @@ -161,6 +160,8 @@ class Apk(PackageManager[ApkEngine]): _engine_class = ApkEngine + _PACKAGE_NAME_IN_PM_OUTPUT_PATTERNS = [_UNABLE_TO_LOCATE_PATTERN, _NO_SUCH_PACKAGE_PATTERN] + probe_command = Command('apk', '--version') def check_presence(self, *installables: Installable) -> dict[Installable, bool]: @@ -195,11 +196,3 @@ def install_debuginfo( options: Optional[Options] = None, ) -> CommandOutput: raise tmt.utils.GeneralError("There is no support for debuginfo packages in apk.") - - def extract_package_name_from_package_manager_output(self, output: str) -> Iterator[str]: - """ - Extract failed package names from APK error output. - """ - for pattern in [_UNABLE_TO_LOCATE_PATTERN, _NO_SUCH_PACKAGE_PATTERN]: - for match in pattern.finditer(output): - yield match.group(1) diff --git a/tmt/package_managers/apt.py b/tmt/package_managers/apt.py index d58f2dd53a..a5467dfac4 100644 --- a/tmt/package_managers/apt.py +++ b/tmt/package_managers/apt.py @@ -1,5 +1,4 @@ import re -from collections.abc import Iterator from typing import Optional, Union import tmt.utils @@ -81,9 +80,8 @@ # Compiled regex patterns for APT error messages -_UNABLE_TO_LOCATE_PATTERN = re.compile(r'Unable to locate package\s+([^\s]+)', re.IGNORECASE) -_E_UNABLE_TO_LOCATE_PATTERN = re.compile( - r'E:\s+Unable to locate package\s+([^\s]+)', re.IGNORECASE +_UNABLE_TO_LOCATE_PATTERN = re.compile( + r'(?:E:\s+)?Unable to locate package\s+([^\s]+)', re.IGNORECASE ) @@ -250,6 +248,8 @@ class Apt(PackageManager[AptEngine]): _engine_class = AptEngine + _PACKAGE_NAME_IN_PM_OUTPUT_PATTERNS = [_UNABLE_TO_LOCATE_PATTERN] + probe_command = Command('apt', '--version') def check_presence(self, *installables: Installable) -> dict[Installable, bool]: @@ -289,11 +289,3 @@ def install_debuginfo( options: Optional[Options] = None, ) -> CommandOutput: raise tmt.utils.GeneralError("There is no support for debuginfo packages in apt.") - - def extract_package_name_from_package_manager_output(self, output: str) -> Iterator[str]: - """ - Extract failed package names from APT error output. - """ - for pattern in [_UNABLE_TO_LOCATE_PATTERN, _E_UNABLE_TO_LOCATE_PATTERN]: - for match in pattern.finditer(output): - yield match.group(1) diff --git a/tmt/package_managers/dnf.py b/tmt/package_managers/dnf.py index 93b135002b..f4473befc7 100644 --- a/tmt/package_managers/dnf.py +++ b/tmt/package_managers/dnf.py @@ -1,5 +1,4 @@ import re -from collections.abc import Iterator from typing import TYPE_CHECKING, Any, Optional, cast from tmt._compat.pathlib import Path @@ -217,6 +216,13 @@ class Dnf(PackageManager[DnfEngine]): _engine_class = DnfEngine + _PACKAGE_NAME_IN_PM_OUTPUT_PATTERNS = [ + _NO_PACKAGE_PROVIDES_PATTERN, + _UNABLE_TO_FIND_MATCH_PATTERN, + _NO_MATCH_FOR_ARGUMENT_PATTERN, + _NO_PACKAGE_AVAILABLE_PATTERN, + ] + bootc_builder = True probe_command = ShellScript( @@ -268,19 +274,6 @@ def check_presence(self, *installables: Installable) -> dict[Installable, bool]: return results - def extract_package_name_from_package_manager_output(self, output: str) -> Iterator[str]: - """ - Extract failed package names from DNF/YUM error output. - """ - for pattern in [ - _NO_PACKAGE_PROVIDES_PATTERN, - _UNABLE_TO_FIND_MATCH_PATTERN, - _NO_MATCH_FOR_ARGUMENT_PATTERN, - _NO_PACKAGE_AVAILABLE_PATTERN, - ]: - for match in pattern.finditer(output): - yield match.group(1) - class Dnf5Engine(DnfEngine): _base_command = Command('dnf5') diff --git a/tmt/steps/discover/__init__.py b/tmt/steps/discover/__init__.py index a0a3c57686..64b52123c7 100644 --- a/tmt/steps/discover/__init__.py +++ b/tmt/steps/discover/__init__.py @@ -1,4 +1,5 @@ import abc +from collections import defaultdict from collections.abc import Iterator from typing import TYPE_CHECKING, Any, Optional, TypeVar, cast @@ -286,17 +287,17 @@ def dependencies_to_tests(self) -> tuple[dict[str, list[str]], dict[str, list[st A tuple containing two dictionaries mapping dependencies to tests (required & recommended) """ - required_dependencies_to_tests: dict[str, list[str]] = {} - recommended_dependencies_to_tests: dict[str, list[str]] = {} + required_dependencies_to_tests: dict[str, list[str]] = defaultdict(list) + recommended_dependencies_to_tests: dict[str, list[str]] = defaultdict(list) for test_origin in self.tests(enabled=True): test = test_origin.test test_name = test.name # Collect dependencies separately for required and recommended for dependency in test.require: - required_dependencies_to_tests.setdefault(str(dependency), []).append(test_name) + required_dependencies_to_tests[str(dependency)].append(test_name) for dependency in test.recommend: - recommended_dependencies_to_tests.setdefault(str(dependency), []).append(test_name) + recommended_dependencies_to_tests[(str(dependency))].append(test_name) return required_dependencies_to_tests, recommended_dependencies_to_tests From 34eae31a3b0514339bc1aa3e7ad0434a0f7d8e55 Mon Sep 17 00:00:00 2001 From: Tomas Bajer Date: Tue, 13 Jan 2026 19:51:11 +0100 Subject: [PATCH 4/4] Moved import to the top --- tmt/steps/prepare/install.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tmt/steps/prepare/install.py b/tmt/steps/prepare/install.py index 593120fd86..dd285cee33 100644 --- a/tmt/steps/prepare/install.py +++ b/tmt/steps/prepare/install.py @@ -1,3 +1,4 @@ +import itertools import re import shutil from collections.abc import Iterator @@ -971,7 +972,6 @@ def _extract_failed_packages_from_outputs( Returns a set of package names that failed to install. """ - import itertools def _extract_from_output(output: tmt.utils.CommandOutput) -> Iterator[str]: if output.stderr: