diff --git a/dnf.spec b/dnf.spec index e70b24e29847d67a481ae5e3e268cf90aca4440c..ba9f41b1db050821520d7b68cb5b67d88c4cbccc 100644 --- a/dnf.spec +++ b/dnf.spec @@ -3,7 +3,7 @@ Name: dnf Version: 4.2.23 -Release: 11 +Release: 12 Summary: A software package manager that manages packages on Linux distributions. License: GPLv2+ and GPLv2 and GPL URL: https://github.com/rpm-software-management/dnf @@ -29,6 +29,8 @@ Patch6003: backport-set-default-value-for-variable-to-prevent-crash.p Patch6004: backport-pass-whole-url-in-relativeUrl-to-packageTarget-for-rpm-url-download.patch Patch6005: backport-ignore-processing-variable-files-with-unsupported-encoding.patch Patch6006: backport-fix-AttributeError-when-IO-busy-and-press-ctrl-c.patch +Patch6007: remove-history-base-add-transaction_sr.patch + BuildArch: noarch BuildRequires: cmake gettext systemd bash-completion python3-sphinx @@ -219,6 +221,11 @@ popd %{_mandir}/man8/%{name}-automatic.8* %changelog +* Mon May 3 2025 xzf1244 4.2.23-11 +- CVE:NA +- SUG:NA +- DESC:Remove Base._history_undo_operations() as it was replaced with transaction_sr code + * Fri May 5 2023 xzf1244 4.2.23-11 - CVE:NA - SUG:NA diff --git a/remove-history-base-add-transaction_sr.patch b/remove-history-base-add-transaction_sr.patch new file mode 100644 index 0000000000000000000000000000000000000000..f1efa6fbfb1c8923924e23c2c96a4412fbe92378 --- /dev/null +++ b/remove-history-base-add-transaction_sr.patch @@ -0,0 +1,1746 @@ +From 4fc6c2df9f45756c701aa51a5691c538d6df6c31 Mon Sep 17 00:00:00 2001 +From: majianhan +Date: Mon, 31 Mar 2025 16:10:20 +0800 +Subject: [PATCH] Remove Base._history_undo_operations() as it was replaced with transaction_sr code + +--- + dnf/base.py | 139 +++----- + dnf/cli/cli.py | 89 +---- + dnf/cli/commands/__init__.py | 4 + + dnf/cli/commands/history.py | 389 +++++++++++++++++++++ + dnf/cli/commands/remove.py | 1 - + dnf/db/history.py | 23 ++ + dnf/package.py | 37 +- + dnf/transaction_sr.py | 639 +++++++++++++++++++++++++++++++++++ + dnf/util.py | 100 +++++- + tests/test_commands.py | 8 +- + 10 files changed, 1245 insertions(+), 184 deletions(-) + create mode 100644 dnf/cli/commands/history.py + create mode 100644 dnf/transaction_sr.py + +diff --git a/dnf/base.py b/dnf/base.py +index e1aa2bd..fd0ab57 100644 +--- a/dnf/base.py ++++ b/dnf/base.py +@@ -28,12 +28,12 @@ import argparse + import dnf + import libdnf.transaction + ++from copy import deepcopy + from dnf.comps import CompsQuery + from dnf.i18n import _, P_, ucd + from dnf.util import _parse_specs + from dnf.db.history import SwdbInterface + from dnf.yum import misc +-from functools import reduce + try: + from collections.abc import Sequence + except ImportError: +@@ -543,7 +543,7 @@ class Base(object): + if self.conf.ignorearch: + self._rpm_probfilter.add(rpm.RPMPROB_FILTER_IGNOREARCH) + +- probfilter = reduce(operator.or_, self._rpm_probfilter, 0) ++ probfilter = functools.reduce(operator.or_, self._rpm_probfilter, 0) + self._priv_ts.setProbFilter(probfilter) + return self._priv_ts + +@@ -613,6 +613,9 @@ class Base(object): + ts = self.history.rpm + all_obsoleted = set(goal.list_obsoleted()) + installonly_query = self._get_installonly_query() ++ installonly_query = self._get_installonly_query() ++ installonly_query.apply() ++ installonly_query_installed = installonly_query.installed().apply() + + for pkg in goal.list_downgrades(): + obs = goal.obsoleted_by_package(pkg) +@@ -644,11 +647,8 @@ class Base(object): + + reason = goal.get_reason(pkg) + +- if pkg in installonly_query: ++ if pkg in installonly_query and installonly_query_installed.filter(name=pkg.name): + reason_installonly = ts.get_reason(pkg) +- if libdnf.transaction.TransactionItemReasonCompare( +- reason, reason_installonly) == -1: +- reason = reason_installonly + + # inherit the best reason from obsoleted packages + for obsolete in obs: +@@ -685,10 +685,18 @@ class Base(object): + ts.add_upgrade(pkg, upgraded, obs) + self._ds_callback.pkg_added(upgraded, 'ud') + self._ds_callback.pkg_added(pkg, 'u') +- for pkg in goal.list_erasures(): +- self._ds_callback.pkg_added(pkg, 'e') +- reason = goal.get_reason(pkg) +- ts.add_erase(pkg, reason) ++ erasures = goal.list_erasures() ++ if erasures: ++ remaining_installed_query = self.sack.query(flags=hawkey.IGNORE_EXCLUDES).installed() ++ remaining_installed_query.filterm(pkg__neq=erasures) ++ for pkg in erasures: ++ if remaining_installed_query.filter(name=pkg.name): ++ remaining = remaining_installed_query[0] ++ ts.get_reason(remaining) ++ self.history.set_reason(remaining, ts.get_reason(remaining)) ++ self._ds_callback.pkg_added(pkg, 'e') ++ reason = goal.get_reason(pkg) ++ ts.add_erase(pkg, reason) + return ts + + def _query_matches_installed(self, q): +@@ -1305,7 +1313,7 @@ class Base(object): + if patterns is None or len(patterns) == 0: + return list_fn(None) + yghs = map(list_fn, patterns) +- return reduce(lambda a, b: a.merge_lists(b), yghs) ++ return functools.reduce(lambda a, b: a.merge_lists(b), yghs) + + def _list_pattern(self, pkgnarrow, pattern, showdups, ignore_case, + reponame=None): +@@ -1974,11 +1982,18 @@ class Base(object): + logger.info(msg, pkg.location) + return 0 + +- q = self.sack.query().installed().filterm(name=pkg.name, arch=[pkg.arch, "noarch"]) ++ installed = self.sack.query().installed().apply() ++ if self.conf.obsoletes and self.sack.query().filterm(pkg=[pkg]).filterm(obsoletes=installed): ++ sltr = dnf.selector.Selector(self.sack) ++ sltr.set(pkg=[pkg]) ++ self._goal.upgrade(select=sltr) ++ return 1 ++ q = installed.filter(name=pkg.name, arch=[pkg.arch, "noarch"]) + if not q: + msg = _("Package %s not installed, cannot update it.") + logger.warning(msg, pkg.name) +- raise dnf.exceptions.MarkingError(_('No match for argument: %s') % pkg.location, pkg.name) ++ raise dnf.exceptions.MarkingError( ++ _('No match for argument: %s') % pkg.location, pkg.name) + elif sorted(q)[-1] < pkg: + sltr = dnf.selector.Selector(self.sack) + sltr.set(pkg=[pkg]) +@@ -1992,20 +2007,24 @@ class Base(object): + + def _upgrade_internal(self, query, obsoletes, reponame, pkg_spec=None): + installed_all = self.sack.query().installed() ++ # Add only relevant obsoletes to transaction => installed, upgrades + q = query.intersection(self.sack.query().filterm(name=[pkg.name for pkg in installed_all])) + installed_query = q.installed() ++ installed_query = q.installed() + if obsoletes: + obsoletes = self.sack.query().available().filterm( + obsoletes=installed_query.union(q.upgrades())) + # add obsoletes into transaction + q = q.union(obsoletes) + if reponame is not None: +- q.filterm(reponame=reponame) +- q = self._merge_update_filters(q, pkg_spec=pkg_spec) +- if q: ++ query.filterm(reponame=reponame) ++ query = self._merge_update_filters(query, pkg_spec=pkg_spec, upgrade=True) ++ if query: ++ query = query.union(installed_query.latest()) ++ q = q.union(installed_all.latest().filter(name=[pkg.name for pkg in query])) + q = q.available().union(installed_query.latest()) + sltr = dnf.selector.Selector(self.sack) +- sltr.set(pkg=q) ++ sltr.set(pkg=query) + self._goal.upgrade(select=sltr) + return 1 + +@@ -2020,18 +2039,21 @@ class Base(object): + # wildcard shouldn't print not installed packages + # only solution with nevra.name provide packages with same name + if not wildcard and solution['nevra'] and solution['nevra'].name: +- installed = self.sack.query().installed() + pkg_name = solution['nevra'].name +- installed.filterm(name=pkg_name).apply() +- if not installed: +- msg = _('Package %s available, but not installed.') +- logger.warning(msg, pkg_name) +- raise dnf.exceptions.PackagesNotInstalledError( +- _('No match for argument: %s') % pkg_spec, pkg_spec) +- if solution['nevra'].arch and not dnf.util.is_glob_pattern(solution['nevra'].arch): +- if not installed.filter(arch=solution['nevra'].arch): +- msg = _('Package %s available, but installed for different architecture.') +- logger.warning(msg, "{}.{}".format(pkg_name, solution['nevra'].arch)) ++ installed = self.sack.query().installed().apply() ++ obsoleters = q.filter(obsoletes=installed) \ ++ if self.conf.obsoletes else self.sack.query().filterm(empty=True) ++ if not obsoleters: ++ installed_name = installed.filter(name=pkg_name).apply() ++ if not installed_name: ++ msg = _('Package %s available, but not installed.') ++ logger.warning(msg, pkg_name) ++ raise dnf.exceptions.PackagesNotInstalledError( ++ _('No match for argument: %s') % pkg_spec, pkg_spec) ++ elif solution['nevra'].arch and not dnf.util.is_glob_pattern(solution['nevra'].arch): ++ if not installed_name.filterm(arch=solution['nevra'].arch): ++ msg = _('Package %s available, but installed for different architecture.') ++ logger.warning(msg, "{}.{}".format(pkg_name, solution['nevra'].arch)) + obsoletes = self.conf.obsoletes and solution['nevra'] \ + and solution['nevra'].has_just_name() + return self._upgrade_internal(q, obsoletes, reponame, pkg_spec) +@@ -2210,66 +2232,7 @@ class Base(object): + for prefix in ['/bin/', '/sbin/', '/usr/bin/', '/usr/sbin/']] + return self.sack.query().filterm(file__glob=binary_provides), binary_provides + +- def _history_undo_operations(self, operations, first_trans, rollback=False, strict=True): +- """Undo the operations on packages by their NEVRAs. +- +- :param operations: a NEVRAOperations to be undone +- :param first_trans: first transaction id being undone +- :param rollback: True if transaction is performing a rollback +- :param strict: if True, raise an exception on any errors +- """ +- +- # map actions to their opposites +- action_map = { +- libdnf.transaction.TransactionItemAction_DOWNGRADE: None, +- libdnf.transaction.TransactionItemAction_DOWNGRADED: libdnf.transaction.TransactionItemAction_UPGRADE, +- libdnf.transaction.TransactionItemAction_INSTALL: libdnf.transaction.TransactionItemAction_REMOVE, +- libdnf.transaction.TransactionItemAction_OBSOLETE: None, +- libdnf.transaction.TransactionItemAction_OBSOLETED: libdnf.transaction.TransactionItemAction_INSTALL, +- libdnf.transaction.TransactionItemAction_REINSTALL: None, +- # reinstalls are skipped as they are considered as no-operation from history perspective +- libdnf.transaction.TransactionItemAction_REINSTALLED: None, +- libdnf.transaction.TransactionItemAction_REMOVE: libdnf.transaction.TransactionItemAction_INSTALL, +- libdnf.transaction.TransactionItemAction_UPGRADE: None, +- libdnf.transaction.TransactionItemAction_UPGRADED: libdnf.transaction.TransactionItemAction_DOWNGRADE, +- libdnf.transaction.TransactionItemAction_REASON_CHANGE: None, +- } +- +- failed = False +- for ti in operations.packages(): +- try: +- action = action_map[ti.action] +- except KeyError: +- raise RuntimeError(_("Action not handled: {}".format(action))) +- +- if action is None: +- continue +- +- if action == libdnf.transaction.TransactionItemAction_REMOVE: +- query = self.sack.query().installed().filterm(nevra_strict=str(ti)) +- if not query: +- logger.error(_('No package %s installed.'), ucd(str(ti))) +- failed = True +- continue +- else: +- query = self.sack.query().filterm(nevra_strict=str(ti)) +- if not query: +- logger.error(_('No package %s available.'), ucd(str(ti))) +- failed = True +- continue +- +- if action == libdnf.transaction.TransactionItemAction_REMOVE: +- for pkg in query: +- self._goal.erase(pkg) +- else: +- selector = dnf.selector.Selector(self.sack) +- selector.set(pkg=query) +- self._goal.install(select=selector, optional=(not strict)) +- +- if strict and failed: +- raise dnf.exceptions.PackageNotFoundError(_('no package matched')) +- +- def _merge_update_filters(self, q, pkg_spec=None, warning=True): ++ def _merge_update_filters(self, q, pkg_spec=None, warning=True, upgrade=False): + """ + Merge Queries in _update_filters and return intersection with q Query + @param q: Query +diff --git a/dnf/cli/cli.py b/dnf/cli/cli.py +index f06e9ab..4824606 100644 +--- a/dnf/cli/cli.py ++++ b/dnf/cli/cli.py +@@ -55,6 +55,7 @@ import dnf.cli.commands.deplist + import dnf.cli.commands.distrosync + import dnf.cli.commands.downgrade + import dnf.cli.commands.group ++import dnf.cli.commands.history + import dnf.cli.commands.install + import dnf.cli.commands.makecache + import dnf.cli.commands.mark +@@ -500,7 +501,7 @@ class BaseCli(dnf.Base): + # XXX put this into the ListCommand at some point + if len(ypl.obsoletes) > 0 and basecmd == 'list': + # if we've looked up obsolete lists and it's a list request +- rop = [0, ''] ++ rop = len(ypl.obsoletes) + print(_('Obsoleting Packages')) + for obtup in sorted(ypl.obsoletesTuples, + key=operator.itemgetter(0)): +@@ -622,90 +623,6 @@ class BaseCli(dnf.Base): + logger.critical(_('Found more than one transaction ID!')) + return old[0] + +- def history_rollback_transaction(self, extcmd): +- """Rollback given transaction.""" +- old = self.history_get_transaction((extcmd,)) +- if old is None: +- return 1, ['Failed history rollback, no transaction'] +- last = self.history.last() +- if last is None: +- return 1, ['Failed history rollback, no last?'] +- if old.tid == last.tid: +- return 0, ['Rollback to current, nothing to do'] +- +- mobj = None +- for trans in self.history.old(list(range(old.tid + 1, last.tid + 1))): +- if trans.altered_lt_rpmdb: +- logger.warning(_('Transaction history is incomplete, before %u.'), trans.tid) +- elif trans.altered_gt_rpmdb: +- logger.warning(_('Transaction history is incomplete, after %u.'), trans.tid) +- +- if mobj is None: +- mobj = dnf.db.history.MergedTransactionWrapper(trans) +- else: +- mobj.merge(trans) +- +- tm = dnf.util.normalize_time(old.beg_timestamp) +- print("Rollback to transaction %u, from %s" % (old.tid, tm)) +- print(self.output.fmtKeyValFill(" Undoing the following transactions: ", +- ", ".join((str(x) for x in mobj.tids())))) +- self.output.historyInfoCmdPkgsAltered(mobj) # :todo +- +-# history = dnf.history.open_history(self.history) # :todo +-# m = libdnf.transaction.MergedTransaction() +- +-# return +- +-# operations = dnf.history.NEVRAOperations() +-# for id_ in range(old.tid + 1, last.tid + 1): +-# operations += history.transaction_nevra_ops(id_) +- +- try: +- self._history_undo_operations(mobj, old.tid + 1, True, strict=self.conf.strict) +- except dnf.exceptions.PackagesNotInstalledError as err: +- raise +- logger.info(_('No package %s installed.'), +- self.output.term.bold(ucd(err.pkg_spec))) +- return 1, ['A transaction cannot be undone'] +- except dnf.exceptions.PackagesNotAvailableError as err: +- raise +- logger.info(_('No package %s available.'), +- self.output.term.bold(ucd(err.pkg_spec))) +- return 1, ['A transaction cannot be undone'] +- except dnf.exceptions.MarkingError: +- raise +- assert False +- else: +- return 2, ["Rollback to transaction %u" % (old.tid,)] +- +- def history_undo_transaction(self, extcmd): +- """Undo given transaction.""" +- old = self.history_get_transaction((extcmd,)) +- if old is None: +- return 1, ['Failed history undo'] +- +- tm = dnf.util.normalize_time(old.beg_timestamp) +- msg = _("Undoing transaction {}, from {}").format(old.tid, ucd(tm)) +- logger.info(msg) +- self.output.historyInfoCmdPkgsAltered(old) # :todo +- +- +- mobj = dnf.db.history.MergedTransactionWrapper(old) +- +- try: +- self._history_undo_operations(mobj, old.tid, strict=self.conf.strict) +- except dnf.exceptions.PackagesNotInstalledError as err: +- logger.info(_('No package %s installed.'), +- self.output.term.bold(ucd(err.pkg_spec))) +- return 1, ['An operation cannot be undone'] +- except dnf.exceptions.PackagesNotAvailableError as err: +- logger.info(_('No package %s available.'), +- self.output.term.bold(ucd(err.pkg_spec))) +- return 1, ['An operation cannot be undone'] +- except dnf.exceptions.MarkingError: +- raise +- else: +- return 2, ["Undoing transaction %u" % (old.tid,)] + + class Cli(object): + def __init__(self, base): +@@ -722,6 +639,7 @@ class Cli(object): + self.register_command(dnf.cli.commands.deplist.DeplistCommand) + self.register_command(dnf.cli.commands.downgrade.DowngradeCommand) + self.register_command(dnf.cli.commands.group.GroupCommand) ++ self.register_command(dnf.cli.commands.history.HistoryCommand) + self.register_command(dnf.cli.commands.install.InstallCommand) + self.register_command(dnf.cli.commands.makecache.MakeCacheCommand) + self.register_command(dnf.cli.commands.mark.MarkCommand) +@@ -742,7 +660,6 @@ class Cli(object): + self.register_command(dnf.cli.commands.CheckUpdateCommand) + self.register_command(dnf.cli.commands.RepoPkgsCommand) + self.register_command(dnf.cli.commands.HelpCommand) +- self.register_command(dnf.cli.commands.HistoryCommand) + + def _configure_repos(self, opts): + self.base.read_all_repos(opts) +diff --git a/dnf/cli/commands/__init__.py b/dnf/cli/commands/__init__.py +index 7762627..6da82e1 100644 +--- a/dnf/cli/commands/__init__.py ++++ b/dnf/cli/commands/__init__.py +@@ -154,6 +154,10 @@ class Command(object): + """Finalize operation after resolvement""" + pass + ++ def run_resolved(self): ++ """Finalize operation after resolvement""" ++ pass ++ + def run_transaction(self): + """Finalize operations post-transaction.""" + pass +diff --git a/dnf/cli/commands/history.py b/dnf/cli/commands/history.py +new file mode 100644 +index 0000000..19cecd3 +--- /dev/null ++++ b/dnf/cli/commands/history.py +@@ -0,0 +1,389 @@ ++# Copyright 2006 Duke University ++# Copyright (C) 2012-2016 Red Hat, Inc. ++# ++# This program is free software; you can redistribute it and/or modify ++# it under the terms of the GNU General Public License as published by ++# the Free Software Foundation; either version 2 of the License, or ++# (at your option) any later version. ++# ++# This program is distributed in the hope that it will be useful, ++# but WITHOUT ANY WARRANTY; without even the implied warranty of ++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++# GNU Library General Public License for more details. ++# ++# You should have received a copy of the GNU General Public License ++# along with this program; if not, write to the Free Software ++# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. ++ ++from __future__ import absolute_import ++from __future__ import print_function ++from __future__ import unicode_literals ++ ++import libdnf ++import hawkey ++ ++from dnf.i18n import _, ucd ++from dnf.cli import commands ++from dnf.transaction_sr import TransactionReplay, serialize_transaction ++ ++import dnf.cli ++import dnf.exceptions ++import dnf.transaction ++import dnf.util ++ ++import json ++import logging ++import os ++ ++ ++logger = logging.getLogger('dnf') ++ ++ ++class HistoryCommand(commands.Command): ++ """A class containing methods needed by the cli to execute the ++ history command. ++ """ ++ ++ aliases = ('history', 'hist') ++ summary = _('display, or use, the transaction history') ++ ++ _CMDS = ['list', 'info', 'redo', 'replay', 'rollback', 'store', 'undo', 'userinstalled'] ++ ++ def __init__(self, *args, **kw): ++ super(HistoryCommand, self).__init__(*args, **kw) ++ ++ self._require_one_transaction_id = False ++ ++ @staticmethod ++ def set_argparser(parser): ++ parser.add_argument('transactions_action', nargs='?', metavar="COMMAND", ++ help="Available commands: {} (default), {}".format( ++ HistoryCommand._CMDS[0], ++ ", ".join(HistoryCommand._CMDS[1:]))) ++ parser.add_argument('--reverse', action='store_true', ++ help="display history list output reversed") ++ parser.add_argument("-o", "--output", default=None, ++ help=_("For the store command, file path to store the transaction to")) ++ parser.add_argument("--ignore-installed", action="store_true", ++ help=_("For the replay command, don't check for installed packages matching " ++ "those in transaction")) ++ parser.add_argument("--ignore-extras", action="store_true", ++ help=_("For the replay command, don't check for extra packages pulled " ++ "into the transaction")) ++ parser.add_argument("--skip-unavailable", action="store_true", ++ help=_("For the replay command, skip packages that are not available or have " ++ "missing dependencies")) ++ parser.add_argument('transactions', nargs='*', metavar="TRANSACTION", ++ help="For commands working with history transactions, " ++ "Transaction ID (, 'last' or 'last-' " ++ "for one transaction, .. " ++ "for a range)") ++ parser.add_argument('transaction_filename', nargs='?', metavar="TRANSACTION_FILE", ++ help="For the replay command, path to the stored " ++ "transaction file to replay") ++ ++ def configure(self): ++ if not self.opts.transactions_action: ++ # no positional argument given ++ self.opts.transactions_action = self._CMDS[0] ++ elif self.opts.transactions_action not in self._CMDS: ++ # first positional argument is not a command ++ self.opts.transactions.insert(0, self.opts.transactions_action) ++ self.opts.transactions_action = self._CMDS[0] ++ ++ self._require_one_transaction_id_msg = _("Found more than one transaction ID.\n" ++ "'{}' requires one transaction ID or package name." ++ ).format(self.opts.transactions_action) ++ ++ demands = self.cli.demands ++ if self.opts.transactions_action == 'replay': ++ if not self.opts.transactions: ++ raise dnf.cli.CliError(_('No transaction file name given.')) ++ if len(self.opts.transactions) > 1: ++ raise dnf.cli.CliError(_('More than one argument given as transaction file name.')) ++ ++ # in case of replay, copy over the file name to it's appropriate variable ++ # (the arg parser can't distinguish here) ++ self.opts.transaction_filename = os.path.abspath(self.opts.transactions[0]) ++ self.opts.transactions = [] ++ ++ demands.available_repos = True ++ demands.resolving = True ++ demands.root_user = True ++ ++ # Override configuration options that affect how the transaction is resolved ++ self.base.conf.clean_requirements_on_remove = False ++ self.base.conf.install_weak_deps = False ++ ++ dnf.cli.commands._checkGPGKey(self.base, self.cli) ++ elif self.opts.transactions_action == 'store': ++ self._require_one_transaction_id = True ++ if not self.opts.transactions: ++ raise dnf.cli.CliError(_('No transaction ID or package name given.')) ++ elif self.opts.transactions_action in ['redo', 'undo', 'rollback']: ++ demands.available_repos = True ++ demands.resolving = True ++ demands.root_user = True ++ ++ self._require_one_transaction_id = True ++ if not self.opts.transactions: ++ msg = _('No transaction ID or package name given.') ++ logger.critical(msg) ++ raise dnf.cli.CliError(msg) ++ elif len(self.opts.transactions) > 1: ++ logger.critical(self._require_one_transaction_id_msg) ++ raise dnf.cli.CliError(self._require_one_transaction_id_msg) ++ demands.available_repos = True ++ dnf.cli.commands._checkGPGKey(self.base, self.cli) ++ else: ++ demands.fresh_metadata = False ++ demands.sack_activation = True ++ if self.base.history.path != ":memory:" and not os.access(self.base.history.path, os.R_OK): ++ msg = _("You don't have access to the history DB: %s" % self.base.history.path) ++ logger.critical(msg) ++ raise dnf.cli.CliError(msg) ++ ++ def get_error_output(self, error): ++ """Get suggestions for resolving the given error.""" ++ if isinstance(error, dnf.exceptions.TransactionCheckError): ++ if self.opts.transactions_action == 'undo': ++ id_, = self.opts.transactions ++ return (_('Cannot undo transaction %s, doing so would result ' ++ 'in an inconsistent package database.') % id_,) ++ elif self.opts.transactions_action == 'rollback': ++ id_, = (self.opts.transactions if self.opts.transactions[0] != 'force' ++ else self.opts.transactions[1:]) ++ return (_('Cannot rollback transaction %s, doing so would ' ++ 'result in an inconsistent package database.') % id_,) ++ ++ return dnf.cli.commands.Command.get_error_output(self, error) ++ ++ def _hcmd_redo(self, extcmds): ++ old = self._history_get_transaction(extcmds) ++ data = serialize_transaction(old) ++ self.replay = TransactionReplay( ++ self.base, ++ data=data, ++ ignore_installed=True, ++ ignore_extras=True, ++ skip_unavailable=self.opts.skip_unavailable ++ ) ++ self.replay.run() ++ ++ def _history_get_transactions(self, extcmds): ++ if not extcmds: ++ raise dnf.cli.CliError(_('No transaction ID given')) ++ ++ old = self.base.history.old(extcmds) ++ if not old: ++ raise dnf.cli.CliError(_('Transaction ID "{0}" not found.').format(extcmds[0])) ++ return old ++ ++ def _history_get_transaction(self, extcmds): ++ old = self._history_get_transactions(extcmds) ++ if len(old) > 1: ++ raise dnf.cli.CliError(_('Found more than one transaction ID!')) ++ return old[0] ++ ++ def _hcmd_undo(self, extcmds): ++ old = self._history_get_transaction(extcmds) ++ self._revert_transaction(old) ++ ++ def _hcmd_rollback(self, extcmds): ++ old = self._history_get_transaction(extcmds) ++ last = self.base.history.last() ++ ++ merged_trans = None ++ if old.tid != last.tid: ++ # history.old([]) returns all transactions and we don't want that ++ # so skip merging the transactions when trying to rollback to the last transaction ++ # which is the current system state and rollback is not applicable ++ for trans in self.base.history.old(list(range(old.tid + 1, last.tid + 1))): ++ if trans.altered_lt_rpmdb: ++ logger.warning(_('Transaction history is incomplete, before %u.'), trans.tid) ++ elif trans.altered_gt_rpmdb: ++ logger.warning(_('Transaction history is incomplete, after %u.'), trans.tid) ++ ++ if merged_trans is None: ++ merged_trans = dnf.db.history.MergedTransactionWrapper(trans) ++ else: ++ merged_trans.merge(trans) ++ ++ self._revert_transaction(merged_trans) ++ ++ def _revert_transaction(self, trans): ++ action_map = { ++ "Install": "Removed", ++ "Removed": "Install", ++ "Upgrade": "Downgraded", ++ "Upgraded": "Downgrade", ++ "Downgrade": "Upgraded", ++ "Downgraded": "Upgrade", ++ "Reinstalled": "Reinstall", ++ "Reinstall": "Reinstalled", ++ "Obsoleted": "Install", ++ "Obsolete": "Obsoleted", ++ } ++ ++ data = serialize_transaction(trans) ++ ++ # revert actions in the serialized transaction data to perform rollback/undo ++ for content_type in ("rpms", "groups", "environments"): ++ for ti in data.get(content_type, []): ++ ti["action"] = action_map[ti["action"]] ++ ++ if ti["action"] == "Install" and ti.get("reason", None) == "clean": ++ ti["reason"] = "dependency" ++ ++ if ti.get("repo_id") == hawkey.SYSTEM_REPO_NAME: ++ # erase repo_id, because it's not possible to perform forward actions from the @System repo ++ ti["repo_id"] = None ++ ++ self.replay = TransactionReplay( ++ self.base, ++ data=data, ++ ignore_installed=True, ++ ignore_extras=True, ++ skip_unavailable=self.opts.skip_unavailable ++ ) ++ self.replay.run() ++ ++ def _hcmd_userinstalled(self): ++ """Execute history userinstalled command.""" ++ pkgs = tuple(self.base.iter_userinstalled()) ++ n_listed = self.output.listPkgs(pkgs, 'Packages installed by user', 'nevra') ++ if n_listed == 0: ++ raise dnf.cli.CliError(_('No packages to list')) ++ ++ def _args2transaction_ids(self): ++ """Convert commandline arguments to transaction ids""" ++ ++ def str2transaction_id(s): ++ if s == 'last': ++ s = '0' ++ elif s.startswith('last-'): ++ s = s[4:] ++ transaction_id = int(s) ++ if transaction_id <= 0: ++ transaction_id += self.output.history.last().tid ++ return transaction_id ++ ++ tids = set() ++ merged_tids = set() ++ for t in self.opts.transactions: ++ if '..' in t: ++ try: ++ begin_transaction_id, end_transaction_id = t.split('..', 2) ++ except ValueError: ++ logger.critical( ++ _("Invalid transaction ID range definition '{}'.\n" ++ "Use '..'." ++ ).format(t)) ++ raise dnf.cli.CliError ++ cant_convert_msg = _("Can't convert '{}' to transaction ID.\n" ++ "Use '', 'last', 'last-'.") ++ try: ++ begin_transaction_id = str2transaction_id(begin_transaction_id) ++ except ValueError: ++ logger.critical(_(cant_convert_msg).format(begin_transaction_id)) ++ raise dnf.cli.CliError ++ try: ++ end_transaction_id = str2transaction_id(end_transaction_id) ++ except ValueError: ++ logger.critical(_(cant_convert_msg).format(end_transaction_id)) ++ raise dnf.cli.CliError ++ if self._require_one_transaction_id and begin_transaction_id != end_transaction_id: ++ logger.critical(self._require_one_transaction_id_msg) ++ raise dnf.cli.CliError ++ if begin_transaction_id > end_transaction_id: ++ begin_transaction_id, end_transaction_id = \ ++ end_transaction_id, begin_transaction_id ++ merged_tids.add((begin_transaction_id, end_transaction_id)) ++ tids.update(range(begin_transaction_id, end_transaction_id + 1)) ++ else: ++ try: ++ tids.add(str2transaction_id(t)) ++ except ValueError: ++ # not a transaction id, assume it's package name ++ transact_ids_from_pkgname = self.output.history.search([t]) ++ if transact_ids_from_pkgname: ++ tids.update(transact_ids_from_pkgname) ++ else: ++ msg = _("No transaction which manipulates package '{}' was found." ++ ).format(t) ++ if self._require_one_transaction_id: ++ logger.critical(msg) ++ raise dnf.cli.CliError ++ else: ++ logger.info(msg) ++ ++ return sorted(tids, reverse=True), merged_tids ++ ++ def run(self): ++ vcmd = self.opts.transactions_action ++ ++ if vcmd == 'replay': ++ self.replay = TransactionReplay( ++ self.base, ++ filename=self.opts.transaction_filename, ++ ignore_installed = self.opts.ignore_installed, ++ ignore_extras = self.opts.ignore_extras, ++ skip_unavailable = self.opts.skip_unavailable ++ ) ++ self.replay.run() ++ else: ++ tids, merged_tids = self._args2transaction_ids() ++ ++ if vcmd == 'list' and (tids or not self.opts.transactions): ++ self.output.historyListCmd(tids) ++ elif vcmd == 'info' and (tids or not self.opts.transactions): ++ self.output.historyInfoCmd(tids, self.opts.transactions, merged_tids) ++ elif vcmd == 'undo': ++ self._hcmd_undo(tids) ++ elif vcmd == 'redo': ++ self._hcmd_redo(tids) ++ elif vcmd == 'rollback': ++ self._hcmd_rollback(tids) ++ elif vcmd == 'userinstalled': ++ self._hcmd_userinstalled() ++ elif vcmd == 'store': ++ tid = self._history_get_transaction(tids) ++ data = serialize_transaction(tid) ++ try: ++ filename = self.opts.output if self.opts.output is not None else "transaction.json" ++ ++ # it is absolutely possible for both assumeyes and assumeno to be True, go figure ++ if (self.base.conf.assumeno or not self.base.conf.assumeyes) and os.path.isfile(filename): ++ msg = _("{} exists, overwrite?").format(filename) ++ if self.base.conf.assumeno or not self.base.output.userconfirm( ++ msg='\n{} [y/N]: '.format(msg), defaultyes_msg='\n{} [Y/n]: '.format(msg)): ++ print(_("Not overwriting {}, exiting.").format(filename)) ++ return ++ ++ with open(filename, "w") as f: ++ json.dump(data, f, indent=4, sort_keys=True) ++ f.write("\n") ++ ++ print(_("Transaction saved to {}.").format(filename)) ++ ++ except OSError as e: ++ raise dnf.cli.CliError(_('Error storing transaction: {}').format(str(e))) ++ ++ def run_resolved(self): ++ if self.opts.transactions_action not in ("replay", "redo", "rollback", "undo"): ++ return ++ ++ self.replay.post_transaction() ++ ++ def run_transaction(self): ++ if self.opts.transactions_action not in ("replay", "redo", "rollback", "undo"): ++ return ++ ++ warnings = self.replay.get_warnings() ++ if warnings: ++ logger.log( ++ dnf.logging.WARNING, ++ _("Warning, the following problems occurred while running a transaction:") ++ ) ++ for w in warnings: ++ logger.log(dnf.logging.WARNING, " " + w) +diff --git a/dnf/cli/commands/remove.py b/dnf/cli/commands/remove.py +index f50dbd9..013c9a5 100644 +--- a/dnf/cli/commands/remove.py ++++ b/dnf/cli/commands/remove.py +@@ -110,7 +110,6 @@ class RemoveCommand(commands.Command): + return + + if self.opts.oldinstallonly: +- q = self.base.sack.query() + instonly = self.base._get_installonly_query(q.installed()).latest(-1) + # also remove running kernel from the set + kernel = self.base.sack.get_running_kernel() +diff --git a/dnf/db/history.py b/dnf/db/history.py +index 844609f..7c67a1b 100644 +--- a/dnf/db/history.py ++++ b/dnf/db/history.py +@@ -51,6 +51,21 @@ class RPMTransactionItemWrapper(object): + + def match(self, pattern): + return True ++ ++ def is_package(self): ++ return self._item.getRPMItem() is not None ++ ++ def is_group(self): ++ return self._item.getCompsGroupItem() is not None ++ ++ def is_environment(self): ++ return self._item.getCompsEnvironmentItem() is not None ++ ++ def get_group(self): ++ return self._item.getCompsGroupItem() ++ ++ def get_environment(self): ++ return self._item.getCompsEnvironmentItem() + + @property + def name(self): +@@ -78,6 +93,10 @@ class RPMTransactionItemWrapper(object): + return "{}:{}-{}".format(self.epoch, self.version, self.release) + return "{}-{}".format(self.version, self.release) + ++ @property ++ def nevra(self): ++ return self._item.getRPMItem().getNEVRA() ++ + @property + def action(self): + return self._item.getAction() +@@ -94,6 +113,10 @@ class RPMTransactionItemWrapper(object): + def reason(self, value): + return self._item.setReason(value) + ++ @reason.setter ++ def reason(self, value): ++ return self._item.setReason(value) ++ + @property + def action_name(self): + try: +diff --git a/dnf/package.py b/dnf/package.py +index 096fc56..6ad326c 100644 +--- a/dnf/package.py ++++ b/dnf/package.py +@@ -26,11 +26,13 @@ from __future__ import unicode_literals + from dnf.i18n import _ + + import binascii ++import dnf.exceptions + import dnf.rpm + import dnf.yum.misc + import hawkey + import logging + import os ++import rpm + + logger = logging.getLogger("dnf") + +@@ -73,15 +75,27 @@ class Package(hawkey.Package): + + @property + def _from_repo(self): ++ """ ++ For installed packages returns id of repository from which the package was installed ++ prefixed with '@' (if such information is available in the history database). Otherwise ++ returns id of repository the package belongs to (@System for installed packages of unknown ++ origin) ++ """ + pkgrepo = None + if self._from_system: + pkgrepo = self.base.history.repo(self) +- else: +- pkgrepo = {} + if pkgrepo: + return '@' + pkgrepo + return self.reponame + ++ @property ++ def from_repo(self): ++ # :api ++ if self._from_system: ++ return self.base.history.repo(self) ++ return "" ++ ++ + @property + def _header(self): + return dnf.rpm._header(self.localPkg()) +@@ -152,6 +166,23 @@ class Package(hawkey.Package): + # assuming self.source_name is None only for a source package + src_name = self.source_name if self.source_name is not None else self.name + return src_name + self.DEBUGSOURCE_SUFFIX ++ ++ def get_header(self): ++ """ ++ Returns the rpm header of the package if it is installed. If not ++ installed, returns None. The header is not cached, it is retrieved from ++ rpmdb on every call. In case of a failure (e.g. when the rpmdb changes ++ between loading the data and calling this method), raises an instance ++ of PackageNotFoundError. ++ """ ++ if not self._from_system: ++ return None ++ ++ try: ++ # RPMDBI_PACKAGES stands for the header of the package ++ return next(self.base._ts.dbMatch(rpm.RPMDBI_PACKAGES, self.rpmdbid)) ++ except StopIteration: ++ raise dnf.exceptions.PackageNotFoundError("Package not found when attempting to retrieve header", str(self)) + + @property + def source_debug_name(self): +@@ -244,7 +275,7 @@ class Package(hawkey.Package): + return self.location + loc = self.location + if self.repo._repo.isLocal() and self.baseurl and self.baseurl.startswith('file://'): +- return os.path.join(self.baseurl, loc.lstrip("/"))[7:] ++ return os.path.join(self.get_local_baseurl(), loc.lstrip("/")) + if not self._is_local_pkg(): + loc = os.path.basename(loc) + return os.path.join(self.pkgdir, loc.lstrip("/")) +diff --git a/dnf/transaction_sr.py b/dnf/transaction_sr.py +new file mode 100644 +index 0000000..4b9be18 +--- /dev/null ++++ b/dnf/transaction_sr.py +@@ -0,0 +1,639 @@ ++ ++# Copyright (C) 2020 Red Hat, Inc. ++# ++# This program is free software; you can redistribute it and/or modify ++# it under the terms of the GNU General Public License as published by ++# the Free Software Foundation; either version 2 of the License, or ++# (at your option) any later version. ++# ++# This program is distributed in the hope that it will be useful, ++# but WITHOUT ANY WARRANTY; without even the implied warranty of ++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++# GNU Library General Public License for more details. ++# ++# You should have received a copy of the GNU General Public License ++# along with this program; if not, write to the Free Software ++# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. ++ ++from __future__ import absolute_import ++from __future__ import print_function ++from __future__ import unicode_literals ++ ++import libdnf ++import hawkey ++ ++from dnf.i18n import _ ++import dnf.exceptions ++ ++import json ++ ++ ++VERSION_MAJOR = 0 ++VERSION_MINOR = 0 ++VERSION = "%s.%s" % (VERSION_MAJOR, VERSION_MINOR) ++""" ++The version of the stored transaction. ++ ++MAJOR version denotes backwards incompatible changes (old dnf won't work with ++new transaction JSON). ++ ++MINOR version denotes extending the format without breaking backwards ++compatibility (old dnf can work with new transaction JSON). Forwards ++compatibility needs to be handled by being able to process the old format as ++well as the new one. ++""" ++ ++ ++class TransactionError(dnf.exceptions.Error): ++ def __init__(self, msg): ++ super(TransactionError, self).__init__(msg) ++ ++ ++class TransactionReplayError(dnf.exceptions.Error): ++ def __init__(self, filename, errors): ++ """ ++ :param filename: The name of the transaction file being replayed ++ :param errors: a list of error classes or a string with an error description ++ """ ++ ++ # store args in case someone wants to read them from a caught exception ++ self.filename = filename ++ if isinstance(errors, (list, tuple)): ++ self.errors = errors ++ else: ++ self.errors = [errors] ++ ++ if filename: ++ msg = _('The following problems occurred while replaying the transaction from file "{filename}":').format(filename=filename) ++ else: ++ msg = _('The following problems occurred while running a transaction:') ++ ++ for error in self.errors: ++ msg += "\n " + str(error) ++ ++ super(TransactionReplayError, self).__init__(msg) ++ ++ ++class IncompatibleTransactionVersionError(TransactionReplayError): ++ def __init__(self, filename, msg): ++ super(IncompatibleTransactionVersionError, self).__init__(filename, msg) ++ ++ ++def _check_version(version, filename): ++ major, minor = version.split('.') ++ ++ try: ++ major = int(major) ++ except ValueError as e: ++ raise TransactionReplayError( ++ filename, ++ _('Invalid major version "{major}", number expected.').format(major=major) ++ ) ++ ++ try: ++ int(minor) # minor is unused, just check it's a number ++ except ValueError as e: ++ raise TransactionReplayError( ++ filename, ++ _('Invalid minor version "{minor}", number expected.').format(minor=minor) ++ ) ++ ++ if major != VERSION_MAJOR: ++ raise IncompatibleTransactionVersionError( ++ filename, ++ _('Incompatible major version "{major}", supported major version is "{major_supp}".') ++ .format(major=major, major_supp=VERSION_MAJOR) ++ ) ++ ++ ++def serialize_transaction(transaction): ++ """ ++ Serializes a transaction to a data structure that is equivalent to the stored JSON format. ++ :param transaction: the transaction to serialize (an instance of dnf.db.history.TransactionWrapper) ++ """ ++ ++ data = { ++ "version": VERSION, ++ } ++ rpms = [] ++ groups = [] ++ environments = [] ++ ++ if transaction is None: ++ return data ++ ++ for tsi in transaction.packages(): ++ if tsi.is_package(): ++ rpms.append({ ++ "action": tsi.action_name, ++ "nevra": tsi.nevra, ++ "reason": libdnf.transaction.TransactionItemReasonToString(tsi.reason), ++ "repo_id": tsi.from_repo ++ }) ++ ++ elif tsi.is_group(): ++ group = tsi.get_group() ++ ++ group_data = { ++ "action": tsi.action_name, ++ "id": group.getGroupId(), ++ "packages": [], ++ "package_types": libdnf.transaction.compsPackageTypeToString(group.getPackageTypes()) ++ } ++ ++ for pkg in group.getPackages(): ++ group_data["packages"].append({ ++ "name": pkg.getName(), ++ "installed": pkg.getInstalled(), ++ "package_type": libdnf.transaction.compsPackageTypeToString(pkg.getPackageType()) ++ }) ++ ++ groups.append(group_data) ++ ++ elif tsi.is_environment(): ++ env = tsi.get_environment() ++ ++ env_data = { ++ "action": tsi.action_name, ++ "id": env.getEnvironmentId(), ++ "groups": [], ++ "package_types": libdnf.transaction.compsPackageTypeToString(env.getPackageTypes()) ++ } ++ ++ for grp in env.getGroups(): ++ env_data["groups"].append({ ++ "id": grp.getGroupId(), ++ "installed": grp.getInstalled(), ++ "group_type": libdnf.transaction.compsPackageTypeToString(grp.getGroupType()) ++ }) ++ ++ environments.append(env_data) ++ ++ if rpms: ++ data["rpms"] = rpms ++ ++ if groups: ++ data["groups"] = groups ++ ++ if environments: ++ data["environments"] = environments ++ ++ return data ++ ++ ++class TransactionReplay(object): ++ """ ++ A class that encapsulates replaying a transaction. The transaction data are ++ loaded and stored when the class is initialized. The transaction is run by ++ calling the `run()` method, after the transaction is created (but before it is ++ performed), the `post_transaction()` method needs to be called to verify no ++ extra packages were pulled in and also to fix the reasons. ++ """ ++ ++ def __init__( ++ self, ++ base, ++ filename="", ++ data=None, ++ ignore_extras=False, ++ ignore_installed=False, ++ skip_unavailable=False ++ ): ++ """ ++ :param base: the dnf base ++ :param filename: the filename to load the transaction from (conflicts with the 'data' argument) ++ :param data: the dictionary to load the transaction from (conflicts with the 'filename' argument) ++ :param ignore_extras: whether to ignore extra package pulled into the transaction ++ :param ignore_installed: whether to ignore installed versions of packages ++ :param skip_unavailable: whether to skip transaction packages that aren't available ++ """ ++ ++ self._base = base ++ self._filename = filename ++ self._ignore_installed = ignore_installed ++ self._ignore_extras = ignore_extras ++ self._skip_unavailable = skip_unavailable ++ ++ if not self._base.conf.strict: ++ self._skip_unavailable = True ++ ++ self._nevra_cache = set() ++ self._nevra_reason_cache = {} ++ self._warnings = [] ++ ++ if filename and data: ++ raise ValueError(_("Conflicting TransactionReplay arguments have been specified: filename, data")) ++ elif filename: ++ self._load_from_file(filename) ++ else: ++ self._load_from_data(data) ++ ++ ++ def _load_from_file(self, fn): ++ self._filename = fn ++ with open(fn, "r") as f: ++ try: ++ replay_data = json.load(f) ++ except json.decoder.JSONDecodeError as e: ++ raise TransactionReplayError(fn, str(e) + ".") ++ ++ try: ++ self._load_from_data(replay_data) ++ except TransactionError as e: ++ raise TransactionReplayError(fn, e) ++ ++ def _load_from_data(self, data): ++ self._replay_data = data ++ self._verify_toplevel_json(self._replay_data) ++ ++ self._rpms = self._replay_data.get("rpms", []) ++ self._assert_type(self._rpms, list, "rpms", "array") ++ ++ self._groups = self._replay_data.get("groups", []) ++ self._assert_type(self._groups, list, "groups", "array") ++ ++ self._environments = self._replay_data.get("environments", []) ++ self._assert_type(self._environments, list, "environments", "array") ++ ++ def _raise_or_warn(self, warn_only, msg): ++ if warn_only: ++ self._warnings.append(msg) ++ else: ++ raise TransactionError(msg) ++ ++ def _assert_type(self, value, t, id, expected): ++ if not isinstance(value, t): ++ raise TransactionError(_('Unexpected type of "{id}", {exp} expected.').format(id=id, exp=expected)) ++ ++ def _verify_toplevel_json(self, replay_data): ++ fn = self._filename ++ ++ if "version" not in replay_data: ++ raise TransactionReplayError(fn, _('Missing key "{key}".'.format(key="version"))) ++ ++ self._assert_type(replay_data["version"], str, "version", "string") ++ ++ _check_version(replay_data["version"], fn) ++ ++ def _replay_pkg_action(self, pkg_data): ++ try: ++ action = pkg_data["action"] ++ nevra = pkg_data["nevra"] ++ repo_id = pkg_data["repo_id"] ++ reason = libdnf.transaction.StringToTransactionItemReason(pkg_data["reason"]) ++ except KeyError as e: ++ raise TransactionError( ++ _('Missing object key "{key}" in an rpm.').format(key=e.args[0]) ++ ) ++ except IndexError as e: ++ raise TransactionError( ++ _('Unexpected value of package reason "{reason}" for rpm nevra "{nevra}".') ++ .format(reason=pkg_data["reason"], nevra=nevra) ++ ) ++ ++ subj = hawkey.Subject(nevra) ++ parsed_nevras = subj.get_nevra_possibilities(forms=[hawkey.FORM_NEVRA]) ++ ++ if len(parsed_nevras) != 1: ++ raise TransactionError(_('Cannot parse NEVRA for package "{nevra}".').format(nevra=nevra)) ++ ++ parsed_nevra = parsed_nevras[0] ++ na = "%s.%s" % (parsed_nevra.name, parsed_nevra.arch) ++ ++ query_na = self._base.sack.query().filter(name=parsed_nevra.name, arch=parsed_nevra.arch) ++ ++ epoch = parsed_nevra.epoch if parsed_nevra.epoch is not None else 0 ++ query = query_na.filter(epoch=epoch, version=parsed_nevra.version, release=parsed_nevra.release) ++ ++ # In case the package is found in the same repo as in the original ++ # transaction, limit the query to that plus installed packages. IOW ++ # remove packages with the same NEVRA in case they are found in ++ # multiple repos and the repo the package came from originally is one ++ # of them. ++ # This can e.g. make a difference in the system-upgrade plugin, in case ++ # the same NEVRA is in two repos, this makes sure the same repo is used ++ # for both download and upgrade steps of the plugin. ++ if repo_id: ++ query_repo = query.filter(reponame=repo_id) ++ if query_repo: ++ query = query_repo.union(query.installed()) ++ ++ if not query: ++ self._raise_or_warn(self._skip_unavailable, _('Cannot find rpm nevra "{nevra}".').format(nevra=nevra)) ++ return ++ ++ # a cache to check no extra packages were pulled into the transaction ++ if action != "Reason Change": ++ self._nevra_cache.add(nevra) ++ ++ # store reasons for forward actions and "Removed", the rest of the ++ # actions reasons should stay as they were determined by the transaction ++ if action in ("Install", "Upgrade", "Downgrade", "Reinstall", "Removed"): ++ self._nevra_reason_cache[nevra] = reason ++ ++ if action in ("Install", "Upgrade", "Downgrade"): ++ if action == "Install" and query_na.installed() and not self._base._get_installonly_query(query_na): ++ self._raise_or_warn(self._ignore_installed, ++ _('Package "{na}" is already installed for action "{action}".').format(na=na, action=action)) ++ ++ sltr = dnf.selector.Selector(self._base.sack).set(pkg=query) ++ self._base.goal.install(select=sltr, optional=not self._base.conf.strict) ++ elif action == "Reinstall": ++ query = query.available() ++ ++ if not query: ++ self._raise_or_warn(self._skip_unavailable, ++ _('Package nevra "{nevra}" not available in repositories for action "{action}".') ++ .format(nevra=nevra, action=action)) ++ return ++ ++ sltr = dnf.selector.Selector(self._base.sack).set(pkg=query) ++ self._base.goal.install(select=sltr, optional=not self._base.conf.strict) ++ elif action in ("Upgraded", "Downgraded", "Reinstalled", "Removed", "Obsoleted"): ++ query = query.installed() ++ ++ if not query: ++ self._raise_or_warn(self._ignore_installed, ++ _('Package nevra "{nevra}" not installed for action "{action}".').format(nevra=nevra, action=action)) ++ return ++ ++ # erasing the original version (the reverse part of an action like ++ # e.g. upgrade) is more robust, but we can't do it if ++ # skip_unavailable is True, because if the forward part of the ++ # action is skipped, we would simply remove the package here ++ if not self._skip_unavailable or action == "Removed": ++ for pkg in query: ++ self._base.goal.erase(pkg, clean_deps=False) ++ elif action == "Reason Change": ++ self._base.history.set_reason(query[0], reason) ++ else: ++ raise TransactionError( ++ _('Unexpected value of package action "{action}" for rpm nevra "{nevra}".') ++ .format(action=action, nevra=nevra) ++ ) ++ ++ def _create_swdb_group(self, group_id, pkg_types, pkgs): ++ comps_group = self._base.comps._group_by_id(group_id) ++ if not comps_group: ++ self._raise_or_warn(self._skip_unavailable, _("Group id '%s' is not available.") % group_id) ++ return None ++ ++ swdb_group = self._base.history.group.new(group_id, comps_group.name, comps_group.ui_name, pkg_types) ++ ++ try: ++ for pkg in pkgs: ++ name = pkg["name"] ++ self._assert_type(name, str, "groups.packages.name", "string") ++ installed = pkg["installed"] ++ self._assert_type(installed, bool, "groups.packages.installed", "boolean") ++ package_type = pkg["package_type"] ++ self._assert_type(package_type, str, "groups.packages.package_type", "string") ++ ++ try: ++ swdb_group.addPackage(name, installed, libdnf.transaction.stringToCompsPackageType(package_type)) ++ except libdnf.error.Error as e: ++ raise TransactionError(str(e)) ++ ++ except KeyError as e: ++ raise TransactionError( ++ _('Missing object key "{key}" in groups.packages.').format(key=e.args[0]) ++ ) ++ ++ return swdb_group ++ ++ def _swdb_group_install(self, group_id, pkg_types, pkgs): ++ swdb_group = self._create_swdb_group(group_id, pkg_types, pkgs) ++ ++ if swdb_group is not None: ++ self._base.history.group.install(swdb_group) ++ ++ def _swdb_group_upgrade(self, group_id, pkg_types, pkgs): ++ if not self._base.history.group.get(group_id): ++ self._raise_or_warn( self._ignore_installed, _("Group id '%s' is not installed.") % group_id) ++ return ++ ++ swdb_group = self._create_swdb_group(group_id, pkg_types, pkgs) ++ ++ if swdb_group is not None: ++ self._base.history.group.upgrade(swdb_group) ++ ++ def _swdb_group_remove(self, group_id, pkg_types, pkgs): ++ if not self._base.history.group.get(group_id): ++ self._raise_or_warn(self._ignore_installed, _("Group id '%s' is not installed.") % group_id) ++ return ++ ++ swdb_group = self._create_swdb_group(group_id, pkg_types, pkgs) ++ ++ if swdb_group is not None: ++ self._base.history.group.remove(swdb_group) ++ ++ def _create_swdb_environment(self, env_id, pkg_types, groups): ++ comps_env = self._base.comps._environment_by_id(env_id) ++ if not comps_env: ++ self._raise_or_warn(self._skip_unavailable, _("Environment id '%s' is not available.") % env_id) ++ return None ++ ++ swdb_env = self._base.history.env.new(env_id, comps_env.name, comps_env.ui_name, pkg_types) ++ ++ try: ++ for grp in groups: ++ id = grp["id"] ++ self._assert_type(id, str, "environments.groups.id", "string") ++ installed = grp["installed"] ++ self._assert_type(installed, bool, "environments.groups.installed", "boolean") ++ group_type = grp["group_type"] ++ self._assert_type(group_type, str, "environments.groups.group_type", "string") ++ ++ try: ++ group_type = libdnf.transaction.stringToCompsPackageType(group_type) ++ except libdnf.error.Error as e: ++ raise TransactionError(str(e)) ++ ++ if group_type not in ( ++ libdnf.transaction.CompsPackageType_MANDATORY, ++ libdnf.transaction.CompsPackageType_OPTIONAL ++ ): ++ raise TransactionError( ++ _('Invalid value "{group_type}" of environments.groups.group_type, ' ++ 'only "mandatory" or "optional" is supported.' ++ ).format(group_type=grp["group_type"]) ++ ) ++ ++ swdb_env.addGroup(id, installed, group_type) ++ except KeyError as e: ++ raise TransactionError( ++ _('Missing object key "{key}" in environments.groups.').format(key=e.args[0]) ++ ) ++ ++ return swdb_env ++ ++ def _swdb_environment_install(self, env_id, pkg_types, groups): ++ swdb_env = self._create_swdb_environment(env_id, pkg_types, groups) ++ ++ if swdb_env is not None: ++ self._base.history.env.install(swdb_env) ++ ++ def _swdb_environment_upgrade(self, env_id, pkg_types, groups): ++ if not self._base.history.env.get(env_id): ++ self._raise_or_warn(self._ignore_installed,_("Environment id '%s' is not installed.") % env_id) ++ return ++ ++ swdb_env = self._create_swdb_environment(env_id, pkg_types, groups) ++ ++ if swdb_env is not None: ++ self._base.history.env.upgrade(swdb_env) ++ ++ def _swdb_environment_remove(self, env_id, pkg_types, groups): ++ if not self._base.history.env.get(env_id): ++ self._raise_or_warn(self._ignore_installed, _("Environment id '%s' is not installed.") % env_id) ++ return ++ ++ swdb_env = self._create_swdb_environment(env_id, pkg_types, groups) ++ ++ if swdb_env is not None: ++ self._base.history.env.remove(swdb_env) ++ ++ def get_data(self): ++ """ ++ :returns: the loaded data of the transaction ++ """ ++ ++ return self._replay_data ++ ++ def get_warnings(self): ++ """ ++ :returns: an array of warnings gathered during the transaction replay ++ """ ++ ++ return self._warnings ++ ++ def run(self): ++ """ ++ Replays the transaction. ++ """ ++ ++ fn = self._filename ++ errors = [] ++ ++ for pkg_data in self._rpms: ++ try: ++ self._replay_pkg_action(pkg_data) ++ except TransactionError as e: ++ errors.append(e) ++ ++ for group_data in self._groups: ++ try: ++ action = group_data["action"] ++ group_id = group_data["id"] ++ ++ try: ++ pkg_types = libdnf.transaction.stringToCompsPackageType(group_data["package_types"]) ++ except libdnf.error.Error as e: ++ errors.append(TransactionError(str(e))) ++ continue ++ ++ if action == "Install": ++ self._swdb_group_install(group_id, pkg_types, group_data["packages"]) ++ elif action == "Upgrade": ++ self._swdb_group_upgrade(group_id, pkg_types, group_data["packages"]) ++ elif action == "Removed": ++ self._swdb_group_remove(group_id, pkg_types, group_data["packages"]) ++ else: ++ errors.append(TransactionError( ++ _('Unexpected value of group action "{action}" for group "{group}".') ++ .format(action=action, group=group_id) ++ )) ++ except KeyError as e: ++ errors.append(TransactionError( ++ _('Missing object key "{key}" in a group.').format(key=e.args[0]) ++ )) ++ except TransactionError as e: ++ errors.append(e) ++ ++ for env_data in self._environments: ++ try: ++ action = env_data["action"] ++ env_id = env_data["id"] ++ ++ try: ++ pkg_types = libdnf.transaction.stringToCompsPackageType(env_data["package_types"]) ++ except libdnf.error.Error as e: ++ errors.append(TransactionError(str(e))) ++ continue ++ ++ if action == "Install": ++ self._swdb_environment_install(env_id, pkg_types, env_data["groups"]) ++ elif action == "Upgrade": ++ self._swdb_environment_upgrade(env_id, pkg_types, env_data["groups"]) ++ elif action == "Removed": ++ self._swdb_environment_remove(env_id, pkg_types, env_data["groups"]) ++ else: ++ errors.append(TransactionError( ++ _('Unexpected value of environment action "{action}" for environment "{env}".') ++ .format(action=action, env=env_id) ++ )) ++ except KeyError as e: ++ errors.append(TransactionError( ++ _('Missing object key "{key}" in an environment.').format(key=e.args[0]) ++ )) ++ except TransactionError as e: ++ errors.append(e) ++ ++ if errors: ++ raise TransactionReplayError(fn, errors) ++ ++ def post_transaction(self): ++ """ ++ Sets reasons in the transaction history to values from the stored transaction. ++ ++ Also serves to check whether additional packages were pulled in by the ++ transaction, which results in an error (unless ignore_extras is True). ++ """ ++ ++ if not self._base.transaction: ++ return ++ ++ errors = [] ++ ++ for tsi in self._base.transaction: ++ try: ++ pkg = tsi.pkg ++ except KeyError as e: ++ # the transaction item has no package, happens for action == "Reason Change" ++ continue ++ ++ nevra = str(pkg) ++ ++ if nevra not in self._nevra_cache: ++ # if ignore_installed is True, we don't want to check for ++ # Upgraded/Downgraded/Reinstalled extras in the transaction, ++ # basically those may be installed and we are ignoring them ++ if not self._ignore_installed or not tsi.action in ( ++ libdnf.transaction.TransactionItemAction_UPGRADED, ++ libdnf.transaction.TransactionItemAction_DOWNGRADED, ++ libdnf.transaction.TransactionItemAction_REINSTALLED ++ ): ++ msg = _('Package nevra "{nevra}", which is not present in the transaction file, was pulled ' ++ 'into the transaction.' ++ ).format(nevra=nevra) ++ ++ if not self._ignore_extras: ++ errors.append(TransactionError(msg)) ++ else: ++ self._warnings.append(msg) ++ ++ try: ++ replay_reason = self._nevra_reason_cache[nevra] ++ ++ if tsi.action in ( ++ libdnf.transaction.TransactionItemAction_INSTALL, ++ libdnf.transaction.TransactionItemAction_REMOVE ++ ) or libdnf.transaction.TransactionItemReasonCompare(replay_reason, tsi.reason) > 0: ++ tsi.reason = replay_reason ++ except KeyError as e: ++ # if the pkg nevra wasn't found, we don't want to change the reason ++ pass ++ ++ if errors: ++ raise TransactionReplayError(self._filename, errors) ++ +diff --git a/dnf/util.py b/dnf/util.py +index 8cf3627..5b34aca 100644 +--- a/dnf/util.py ++++ b/dnf/util.py +@@ -24,7 +24,8 @@ from __future__ import unicode_literals + + from .pycomp import PY3, basestring + from dnf.i18n import _, ucd +-from functools import reduce ++import functools ++import hawkey + import argparse + import dnf + import dnf.callback +@@ -41,6 +42,7 @@ import sys + import tempfile + import time + import libdnf.repo ++import libdnf.transaction + + logger = logging.getLogger('dnf') + +@@ -195,7 +197,7 @@ def group_by_filter(fn, iterable): + def splitter(acc, item): + acc[not bool(fn(item))].append(item) + return acc +- return reduce(splitter, iterable, ([], [])) ++ return functools.reduce(splitter, iterable, ([], [])) + + def insert_if(item, iterable, condition): + """Insert an item into an iterable by a condition.""" +@@ -504,3 +506,97 @@ class MultiCallList(list): + def setter(item): + setattr(item, what, val) + return list(map(setter, self)) ++ ++def _make_lists(transaction): ++ b = Bunch({ ++ 'downgraded': [], ++ 'erased': [], ++ 'erased_clean': [], ++ 'erased_dep': [], ++ 'installed': [], ++ 'installed_group': [], ++ 'installed_dep': [], ++ 'installed_weak': [], ++ 'reinstalled': [], ++ 'upgraded': [], ++ 'failed': [], ++ }) ++ ++ for tsi in transaction: ++ if tsi.state == libdnf.transaction.TransactionItemState_ERROR: ++ b.failed.append(tsi) ++ elif tsi.action == libdnf.transaction.TransactionItemAction_DOWNGRADE: ++ b.downgraded.append(tsi) ++ elif tsi.action == libdnf.transaction.TransactionItemAction_INSTALL: ++ if tsi.reason == libdnf.transaction.TransactionItemReason_GROUP: ++ b.installed_group.append(tsi) ++ elif tsi.reason == libdnf.transaction.TransactionItemReason_DEPENDENCY: ++ b.installed_dep.append(tsi) ++ elif tsi.reason == libdnf.transaction.TransactionItemReason_WEAK_DEPENDENCY: ++ b.installed_weak.append(tsi) ++ else: ++ # TransactionItemReason_USER ++ b.installed.append(tsi) ++ elif tsi.action == libdnf.transaction.TransactionItemAction_REINSTALL: ++ b.reinstalled.append(tsi) ++ elif tsi.action == libdnf.transaction.TransactionItemAction_REMOVE: ++ if tsi.reason == libdnf.transaction.TransactionItemReason_CLEAN: ++ b.erased_clean.append(tsi) ++ elif tsi.reason == libdnf.transaction.TransactionItemReason_DEPENDENCY: ++ b.erased_dep.append(tsi) ++ else: ++ b.erased.append(tsi) ++ elif tsi.action == libdnf.transaction.TransactionItemAction_UPGRADE: ++ b.upgraded.append(tsi) ++ ++ return b ++ ++ ++def _post_transaction_output(base, transaction, action_callback): ++ """Returns a human-readable summary of the results of the ++ transaction. ++ :param action_callback: function generating output for specific action. It ++ takes two parameters - action as a string and list of affected packages for ++ this action ++ :return: a list of lines containing a human-readable summary of the ++ results of the transaction ++ """ ++ def _tsi_or_pkg_nevra_cmp(item1, item2): ++ """Compares two transaction items or packages by nevra. ++ Used as a fallback when tsi does not contain package object. ++ """ ++ ret = (item1.name > item2.name) - (item1.name < item2.name) ++ if ret != 0: ++ return ret ++ nevra1 = hawkey.NEVRA(name=item1.name, epoch=item1.epoch, version=item1.version, ++ release=item1.release, arch=item1.arch) ++ nevra2 = hawkey.NEVRA(name=item2.name, epoch=item2.epoch, version=item2.version, ++ release=item2.release, arch=item2.arch) ++ ret = nevra1.evr_cmp(nevra2, base.sack) ++ if ret != 0: ++ return ret ++ return (item1.arch > item2.arch) - (item1.arch < item2.arch) ++ ++ list_bunch = dnf.util._make_lists(transaction) ++ ++ skipped_conflicts, skipped_broken = base._skipped_packages( ++ report_problems=False, transaction=transaction) ++ skipped = skipped_conflicts.union(skipped_broken) ++ ++ out = [] ++ for (action, tsis) in [(_('Upgraded'), list_bunch.upgraded), ++ (_('Downgraded'), list_bunch.downgraded), ++ (_('Installed'), list_bunch.installed + ++ list_bunch.installed_group + ++ list_bunch.installed_weak + ++ list_bunch.installed_dep), ++ (_('Reinstalled'), list_bunch.reinstalled), ++ (_('Skipped'), skipped), ++ (_('Removed'), list_bunch.erased + ++ list_bunch.erased_dep + ++ list_bunch.erased_clean), ++ (_('Failed'), list_bunch.failed)]: ++ out.extend(action_callback( ++ action, sorted(tsis, key=functools.cmp_to_key(_tsi_or_pkg_nevra_cmp)))) ++ ++ return out +diff --git a/tests/test_commands.py b/tests/test_commands.py +index be4060b..5ac87b6 100644 +--- a/tests/test_commands.py ++++ b/tests/test_commands.py +@@ -50,7 +50,7 @@ class CommandsCliTest(tests.support.DnfBaseTestCase): + @mock.patch('dnf.cli.commands._', dnf.pycomp.NullTranslations().ugettext) + def test_history_get_error_output_rollback_transactioncheckerror(self): + """Test get_error_output with the history rollback and a TransactionCheckError.""" +- cmd = dnf.cli.commands.HistoryCommand(self.cli) ++ cmd = dnf.cli.commands.history.HistoryCommand(self.cli) + tests.support.command_configure(cmd, ['rollback', '1']) + + lines = cmd.get_error_output(dnf.exceptions.TransactionCheckError()) +@@ -63,7 +63,7 @@ class CommandsCliTest(tests.support.DnfBaseTestCase): + @mock.patch('dnf.cli.commands._', dnf.pycomp.NullTranslations().ugettext) + def test_history_get_error_output_undo_transactioncheckerror(self): + """Test get_error_output with the history undo and a TransactionCheckError.""" +- cmd = dnf.cli.commands.HistoryCommand(self.cli) ++ cmd = dnf.cli.commands.history.HistoryCommand(self.cli) + tests.support.command_configure(cmd, ['undo', '1']) + + lines = cmd.get_error_output(dnf.exceptions.TransactionCheckError()) +@@ -76,12 +76,12 @@ class CommandsCliTest(tests.support.DnfBaseTestCase): + @mock.patch('dnf.cli.commands._', dnf.pycomp.NullTranslations().ugettext) + def test_history_convert_tids(self): + """Test history _convert_tids().""" +- cmd = dnf.cli.commands.HistoryCommand(self.cli) ++ cmd = dnf.cli.commands.history.HistoryCommand(self.cli) + cmd.cli.base.output = mock.MagicMock() + cmd.cli.base.output.history.last().tid = 123 + cmd.cli.base.output.history.search = mock.MagicMock(return_value=[99]) + tests.support.command_configure(cmd, ['list', '1..5', 'last', 'last-10', 'kernel']) +- self.assertEqual(cmd._args2transaction_ids(), [123, 113, 99, 5, 4, 3, 2, 1]) ++ self.assertEqual(cmd._args2transaction_ids(), ([123, 113, 99, 5, 4, 3, 2, 1], {(1, 5)})) + + + class CommandTest(tests.support.DnfBaseTestCase): +-- +2.39.5 (Apple Git-154) +