Source code for meta_package_manager.managers.dnf

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# 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 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., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

from __future__ import annotations

import re

from extra_platforms import UNIX_WITHOUT_MACOS

from ..capabilities import search_capabilities, version_not_implemented
from ..manager import PackageManager

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Iterator

    from ..package import Package


[docs] class DNF(PackageManager): """Fedora's RPM package manager. `mpm` reads the inventory through `repoquery` rather than the human-facing listing: `--userinstalled` for packages installed on request (dependencies pulled in automatically are skipped), `--upgrades` for pending updates and `--unneeded` for the orphans, each with a `--queryformat` that joins the fields on a private `___MPM___` delimiter so summaries containing spaces stay splittable. Every call is forced `--color=never` and `--quiet` for parseable output. ```{note} `outdated` is the one operation that runs two of those queries. `--upgrades` answers for *available* packages, so it describes the upgrade candidate and never the package installed, and `repoquery` offers no tag for the latter. The installed set is therefore read separately and joined on name and architecture. It is also the one place versions are reported as `%{evr}`, epoch and release included, since an upgrade may move only the release. ``` ```{note} `remove` runs `autoremove`, so removing a package also drops the dependencies it leaves orphaned. `search` matches names only, with no exact or extended mode. ``` The `DNF5` and `YUM` subclasses reuse everything here, differing only in the binary and forced arguments. Documentation: - [DNF command reference](https://dnf.readthedocs.io/en/latest/command_ref.html) - [Command equivalences with other managers](https://wiki.archlinux.org/title/Pacman/Rosetta) """ maintenance_note: str | None = ( "DNF 4 is superseded by " "[dnf5](https://github.com/rpm-software-management/dnf5) (Fedora's default " "since Fedora 41) but stays maintained for the RHEL 8/9 family; mpm wraps " "`dnf5` as a separate manager." ) name = "Fedora DNF" homepage_url = "https://github.com/rpm-software-management/dnf" logo = "fedora" keywords = ("fedora", "redhat", "rhel", "rpm") platforms = UNIX_WITHOUT_MACOS default_sudo = True requirement = ">=4.0.0,<5" """Ceiling because `dnf` is no longer dnf4 everywhere. Fedora 41 and later ship `/usr/bin/dnf` as a symlink to `dnf5`, and {attr}`~meta_package_manager.execution.CLIExecutor.cli_path` returns the first name it finds without consulting the version. So this class is handed a dnf5 binary on a current Fedora, and the ceiling is what makes it decline one, leaving `dnf5` to the `DNF5` subclass that drives it properly. Without it the two managers would report the same RPM database twice. """ cli_names: tuple[str, ...] = ("dnf", "dnf4") """`dnf4` is the fallback for a host that renamed the dnf4 binary. It stays *after* `dnf` deliberately. On RHEL 8 and 9 the only name is `dnf`, which is dnf4; on Fedora both exist, and reaching the vestigial `/usr/bin/dnf4` there would report a second view of the database `dnf5` already covers. """ pre_args: tuple[str, ...] = ("--color=never", "--quiet") version_regexes: tuple[str, ...] = ( r"dnf5\s+version\s+(?P<version>\S+)", r"(?P<version>\S+)", ) """dnf4 prints a bare version, so the first pattern is for the *other* binary. A `dnf` that is really dnf5 opens `dnf5 version 5.4.3.0`, whose first token is the word `dnf5`. Matching that first is what lets the `requirement` ceiling above refuse it by version rather than by a parse accident: the bare fallback alone would read `dnf5` as the version and report that back to the user. ```{code-block} shell-session $ dnf --version 4.24.0 Installed: rpm-0:6.0.2-1.fc44.aarch64 at Fri 04 Sep 2026 08:23:20 AM GMT Built : Fedora Project at Thu 16 Jul 2026 04:13:23 PM GMT ``` """ _ORPHANS_REGEXP = re.compile( r"^(?P<package_id>\S+)-(?:\d+:)?(?P<installed_version>[^-\s]+-[^-\s]+)" r"\.(?P<arch>[^.\s]+)$", re.MULTILINE, ) """Split `repoquery`'s NEVRA lines (`name-[epoch:]version-release.arch`). RPM forbids dashes in the version and release fields, so the last two dash-separated fields before the `.arch` suffix are unambiguously `version-release`, dashes in the package name notwithstanding. """ _SEARCH_REGEXP = re.compile( r"^[ \t]*(?P<package_id>\S+)\.[^.\s]+" r"(?:[ \t]+:[ \t]+|\t)" r"(?P<description>.+)$" ) """Split a `search` hit into its package id and its summary. One pattern for two output shapes, because `yum` fronts either binary. dnf4 writes `usd.aarch64 : 3D VFX pipeline interchange file format`, while dnf5 indents the line and separates the two fields with a tab. Matching both is what keeps the parser working across the Fedora 41 cutover. The trailing `.+` is what captures a summary whole. A `\\S+` there stopped at the first space, so every description was stored as its own first word. Nothing else is needed to reject the section headers both binaries print (dnf4's `===` rules, dnf5's `Matched fields:` lines) or dnf4's metadata banner: an anchored match needs a dotted `name.arch` token, and none of them carry one. """ DELIMITER = "___MPM___" @property def installed(self) -> Iterator[Package]: """Fetch installed packages. ```{code-block} shell-session $ dnf --color=never --quiet repoquery --userinstalled --qf {format} NetworkManager-bluetooth___MPM___1.56.1___MPM___Bluetooth device plugin for NetworkManager___MPM___aarch64 NetworkManager-team___MPM___1.56.1___MPM___Team device plugin for NetworkManager___MPM___aarch64 NetworkManager-wifi___MPM___1.56.1___MPM___Wifi plugin for NetworkManager___MPM___aarch64 ``` `%{version}` is the upstream version alone: the first of those three is installed as `1:1.56.1-2.fc44`, and neither its epoch nor its release reaches this listing. `outdated` below reports `%{evr}` instead, which carries both. """ qf = ["%{name}", "%{version}", "%{summary}", "%{arch}\n"] output = self.run_cli( "repoquery", "--userinstalled", "--qf", self.DELIMITER.join(qf) ) for line_package in output.splitlines(): # remove empty new line if not line_package: continue package_id, installed_version, summary, arch = line_package.split( self.DELIMITER ) yield self.package( id=package_id, description=summary, installed_version=installed_version, arch=arch, ) @property def outdated(self) -> Iterator[Package]: """Fetch outdated packages. Two queries, because one cannot answer both halves. `--upgrades` restricts the query to *available* packages, so every field it returns describes the upgrade candidate and none of them the installed package. `repoquery` exposes no tag for the latter either, `--querytags` listing `epoch`, `evr`, `release` and `version` for whichever package matched. So the installed side is read once, up front, and joined on name and architecture: the pair is what identifies a package on a multilib host, where the same name is installed for two of them. Both sides are reported as `%{evr}`, the epoch-version-release triplet RPM actually orders packages by. A bare `%{version}` would hide the release, and a release-only rebuild (`1.21.0-1.fc44` to `1.21.0-2.fc44`) is a real upgrade that would then read as an identical version on both sides. This is the one operation where that matters, which is why `installed` above still reports `%{version}`: changing it would rewrite the version string every snapshot carries. One format serves both, the summary going unread on the installed pass. That keeps the two outputs the same shape, which is what lets either block stand in for the other when a documented `FORMAT` placeholder leaves the two calls indistinguishable. ```{code-block} shell-session $ dnf --color=never --quiet repoquery --installed --qf {format} librepo___MPM___1.21.0-1.fc44___MPM___Repodata downloading library___MPM___aarch64 openldap___MPM___2.6.13-1.fc44___MPM___LDAP support libraries___MPM___aarch64 wireless-regdb___MPM___2026.05.30-1.fc44___MPM___Regulatory database for 802.11 wireless networking___MPM___noarch ``` ```{code-block} shell-session $ dnf --color=never --quiet repoquery --upgrades --qf {format} librepo___MPM___1.21.0-2.fc44___MPM___Repodata downloading library___MPM___aarch64 openldap___MPM___2.6.14-1.fc44___MPM___LDAP support libraries___MPM___aarch64 wireless-regdb___MPM___2026.09.03-1.fc44___MPM___Regulatory database for 802.11 wireless networking___MPM___noarch ``` """ qf = ["%{name}", "%{evr}", "%{summary}", "%{arch}\n"] query_format = self.DELIMITER.join(qf) installed_evr: dict[tuple[str, str], str] = {} for line_package in self.run_cli( "repoquery", "--installed", "--qf", query_format ).splitlines(): if not line_package: continue name, evr, _summary, arch = line_package.split(self.DELIMITER) installed_evr[(name, arch)] = evr output = self.run_cli("repoquery", "--upgrades", "--qf", query_format) for line_package in output.splitlines(): # remove empty new line if not line_package: continue package_id, last_version, summary, arch = line_package.split(self.DELIMITER) yield self.package( id=package_id, description=summary, # A candidate whose package is somehow not installed keeps a # `None` version rather than being dropped: the upgrade is # pending either way, and hiding it would be the worse answer. installed_version=installed_evr.get((package_id, arch)), arch=arch, latest_version=last_version, ) @property def orphans(self) -> Iterator[Package]: """Fetch packages installed as dependencies that nothing requires anymore. ```{code-block} shell-session $ dnf --color=never --quiet repoquery --unneeded bc-0:1.08.2-4.fc44.aarch64 dos2unix-0:7.5.6-1.fc44.aarch64 tree-0:2.2.1-4.fc44.aarch64 ``` A host reaches this state on its own, but rarely: a fresh install has nothing installed-as-a-dependency and then abandoned, which is why the rows above were made by marking three leaf packages with `dnf mark dependency`. That needs no network and is undone by `dnf mark user`. """ output = self.run_cli("repoquery", "--unneeded") yield from self.parse_regex_lines(self._ORPHANS_REGEXP, output)
[docs] @search_capabilities(extended_support=False, exact_support=False) def search(self, query: str, extended: bool, exact: bool) -> Iterator[Package]: """Fetch matching packages. ```{caution} Search does not support extended or exact matching. So we return the best subset of results and let {meth}`meta_package_manager.manager.PackageManager.refiltered_search` refine them. ``` ```{code-block} shell-session $ dnf4 --color=never --quiet search usd ========================== Name Exactly Matched: usd =========================== usd.aarch64 : 3D VFX pipeline interchange file format ========================= Name & Summary Matched: usd ========================== libbpf-usdt-devel.noarch : The header for defining USDTs python3-usd.aarch64 : Development files for USD usd-devel.aarch64 : Development files for USD ============================== Name Matched: usd =============================== busd.aarch64 : D-Bus bus (broker) implementation lvm2-dbusd.noarch : LVM2 D-Bus daemon rust-busd+default-devel.noarch : D-Bus bus (broker) implementation rust-busd+tracing-subscriber-devel.noarch : D-Bus bus (broker) implementation rust-busd-devel.noarch : D-Bus bus (broker) implementation usd-libs.aarch64 : Universal Scene Description library ``` dnf5 answers in its own shape, which `yum` also produces wherever that name points at dnf5: ```{code-block} shell-session $ dnf --color=never --quiet search bash Matched fields: name (exact) bash.aarch64 The GNU Bourne Again shell Matched fields: name, summary argbash.noarch Bash argument parsing code generator bash-argsparse.noarch An high level argument parsing library for bash ``` """ output = self.run_cli("search", query) for line in output.splitlines(): match = self._SEARCH_REGEXP.match(line) if match: yield self.package( id=match.group("package_id"), description=match.group("description"), )
[docs] @version_not_implemented def install(self, package_id: str, version: str | None = None) -> str: """Install one package. ```{code-block} shell-session $ sudo dnf --color=never --quiet --assumeyes install pip ``` """ return self.run_cli("--assumeyes", "install", package_id, sudo=True)
[docs] def upgrade_all_cli(self) -> tuple[str, ...]: """Generates the CLI to upgrade all outdated packages. ```{code-block} shell-session $ sudo dnf --color=never --quiet --assumeyes upgrade ``` """ return self.build_cli("--assumeyes", "upgrade", sudo=True)
[docs] @version_not_implemented def upgrade_one_cli( self, package_id: str, version: str | None = None, ) -> tuple[str, ...]: """Generates the CLI to upgrade the provided package. ```{code-block} shell-session $ sudo dnf --color=never --quiet --assumeyes upgrade pip ``` """ return self.build_cli("--assumeyes", "upgrade", package_id, sudo=True)
[docs] def remove(self, package_id: str) -> str: """Remove one package and one only. ```{code-block} shell-session $ sudo dnf --color=never --quiet --assumeyes remove pip ``` """ return self.run_cli("--assumeyes", "remove", package_id, sudo=True)
[docs] def remove_orphan(self, package_id: str) -> str: """Remove one package, dropping dependencies it alone pulled in. `autoremove` targets the package plus the dependencies that were installed to satisfy it and are no longer required by anything else. ```{code-block} shell-session $ sudo dnf --color=never --quiet --assumeyes autoremove pip ``` """ return self.run_cli("--assumeyes", "autoremove", package_id, sudo=True)
[docs] def sync(self) -> None: """Sync package metadata. ```{code-block} shell-session $ dnf --color=never --quiet check-update ``` """ self.run_cli("check-update")
[docs] def cleanup_orphan(self) -> None: """Remove every package installed as a dependency and no longer required. ```{code-block} shell-session $ sudo dnf --color=never --quiet --assumeyes autoremove ``` """ self.run_cli("--assumeyes", "autoremove", sudo=True)
[docs] def cleanup_cache(self) -> None: """Clear the cached packages and repository metadata. ```{code-block} shell-session $ sudo dnf --color=never --quiet clean all ``` """ self.run_cli("clean", "all", sudo=True)
[docs] def doctor_cli(self) -> tuple[str, ...]: """Generates the CLI running the native self-diagnosis. `check` examines the rpm database for problems (duplicates, obsoleted packages, unsatisfied dependencies) and exits non-zero when any is found. ```{code-block} shell-session $ dnf --color=never --quiet check ``` """ return self.build_cli("check")
[docs] class DNF5(DNF): """The `dnf5` rewrite of DNF, Fedora's reference package manager since Fedora 41. Inherits every operation and parser from `DNF`. Its forced arguments drop `--color=never` (`dnf5` rejects that option), keeping only `--quiet`. """ # dnf5 is actively developed: clear the maintenance note inherited from DNF. maintenance_note = None name = "Fedora DNF5" homepage_url = "https://github.com/rpm-software-management/dnf5" logo = "fedora" requirement = ">=5.0.0" """dnf5 is the new reference package manager as of Fedora 41.""" cli_names = ("dnf5",) pre_args = ("--quiet",) """Reset global options inherited from the `DNF` above. Kept for the dnf5 releases that rejected `--color=never`. Current ones accept it: `5.4.3.0` exits `0` on the option, where an unknown one exits `2`. """ version_regexes = (r"dnf5\s+version\s+(?P<version>\S+)",) """`dnf5` opens its own name, where dnf4 answers with a bare version. The bare `(?P<version>\\S+)` default reads that first token as the version itself, so the manager reported `dnf5` and then refused its own `requirement`, taking Fedora's reference package manager out of the pool entirely. ```{code-block} shell-session $ dnf5 --version dnf5 version 5.4.3.0 dnf5 plugin API version 2.0 libdnf5 version 5.4.3.0 libdnf5 plugin API version 2.2 ``` """
[docs] class YUM(DNF): """YUM, the package manager DNF superseded. On current Fedora and RHEL the `yum` binary is a wrapper around `dnf`. `mpm` drives it exactly as `DNF`, only the binary name differs. """ maintenance_note = ( "The standalone [yum project is archived]" "(https://github.com/rpm-software-management/yum); on modern RHEL and Fedora " "the `yum` command is a maintained compatibility alias for " "[dnf](https://github.com/rpm-software-management/dnf)." ) name = "Fedora YUM" homepage_url = "http://yum.baseurl.org" logo = "fedora" requirement = ">=4.0.0" """No ceiling, unlike the `DNF` parent. `yum` is a compatibility name rather than a generation: it fronts dnf4 on RHEL 8 and 9, and dnf5 on Fedora 41 and later. Both are the manager this class is for, so it accepts either, and the inherited `version_regexes` already read both shapes. """ cli_names = ("yum",)