From c534a7ed5fa2f2ec345eadb99d588299e73f3aed Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Wed, 15 Jul 2026 16:43:01 -0400 Subject: [PATCH 1/2] Support dnf5 group list/info in pkg.group_list and pkg.group_info (#67975) Backport of the dnf5 group support from #67975 to 3006.x, grafted onto the current group functions so the #60276 multi-word member-group @-fallback is preserved. dnf5 changed the "group list" and "group info" output formats, which the yum/dnf parser did not understand, so the group functions (and pkg.group_installed) returned empty or wrong data on Fedora 41+ and RHEL/AlmaLinux 10. - group_list: parse the dnf5 "group list --hidden" table by tokenizing each row (ID plus the trailing yes/no Installed column) instead of a regex, so a group name that contains or ends in the word "yes"/"no" is not mistaken for the status column, and a row with trailing whitespace is not dropped. - group_info: parse the dnf5 "group info" format, where each package section carries its first member inline after the colon. That inline member is pulled into a dedicated variable rather than mutating the loop's line, blank members are skipped, and section lines are fully stripped on both ends. dnf5 environment and language groups live under a separate "environment" subcommand and are out of scope here, so those keys stay empty on dnf5. Validated end to end against live dnf5 5.4.2.1 on Fedora 44: group_list parses all 157 groups and group_info parses every package with none dropped. Co-authored-by: Greg Oster --- changelog/67975.fixed.md | 1 + salt/modules/yumpkg.py | 96 +++++++++++++++++--- tests/pytests/unit/modules/test_yumpkg.py | 103 ++++++++++++++++++++++ 3 files changed, 189 insertions(+), 11 deletions(-) create mode 100644 changelog/67975.fixed.md diff --git a/changelog/67975.fixed.md b/changelog/67975.fixed.md new file mode 100644 index 00000000000..434e4ebd213 --- /dev/null +++ b/changelog/67975.fixed.md @@ -0,0 +1 @@ +Fixed ``pkg.group_list`` and ``pkg.group_info`` on dnf5 systems (Fedora 41+, RHEL/AlmaLinux 10). dnf5 changed the ``group list``/``group info`` output format, which the yum/dnf parser did not understand, so the group functions (and ``pkg.group_installed``) returned empty or incorrect data. The group name column is now tokenized so a name containing the word "yes" or "no" is no longer mistaken for the installed column. diff --git a/salt/modules/yumpkg.py b/salt/modules/yumpkg.py index f1694e90716..90758bce972 100644 --- a/salt/modules/yumpkg.py +++ b/salt/modules/yumpkg.py @@ -2634,6 +2634,37 @@ def group_list(): "available language groups:": "available languages", } + if _yum() == "dnf5": + # dnf5 lists environment groups and language groups under separate + # subcommands ("dnf5 environment list"), not under "group list", so the + # "installed/available environments" and "available languages" keys + # stay empty on dnf5 (the dnf/yum path below still fills them). + out = __salt__["cmd.run_stdout"]( + [_yum(), "group", "list", "--hidden"], + output_loglevel="trace", + python_shell=False, + ) + for line in salt.utils.itertools.split(out, "\n"): + # dnf5 'group list' is a whitespace-aligned table: + # ID Name (may contain spaces) Installed (yes|no) + # Tokenize the row rather than matching the name with a regex, so a + # group name that happens to contain the word "yes" or "no" cannot + # be mistaken for the trailing Installed column. The header row and + # any blank/administrative lines have no yes/no last column and are + # skipped here. + parts = line.split() + if len(parts) < 3: + continue + installed = parts[-1].lower() + if installed not in ("yes", "no"): + continue + group_id = parts[0] + if installed == "yes": + ret["installed"].append(group_id) + else: + ret["available"].append(group_id) + return ret + out = __salt__["cmd.run_stdout"]( [_yum(), "grouplist", "hidden"], output_loglevel="trace", python_shell=False ) @@ -2737,7 +2768,10 @@ def group_info(name, expand=False, ignore_groups=None, **kwargs): } ) - cmd = [_yum(), "--quiet"] + options + ["groupinfo", name] + if _yum() == "dnf5": + cmd = [_yum(), "--quiet"] + options + ["group", "info", name] + else: + cmd = [_yum(), "--quiet"] + options + ["groupinfo", name] out = __salt__["cmd.run_stdout"](cmd, output_loglevel="trace", python_shell=False) g_info = {} @@ -2752,9 +2786,17 @@ def group_info(name, expand=False, ignore_groups=None, **kwargs): ret["type"] = "environment group" elif "group" in g_info: ret["type"] = "package group" + elif "name" in g_info: + # dnf5 'group info' labels a package group with "Name"/"Id" rather than + # the "Group"/"Group-Id" that dnf uses. + ret["type"] = "package group" - ret["group"] = g_info.get("environment group") or g_info.get("group") - ret["id"] = g_info.get("environment-id") or g_info.get("group-id") + ret["group"] = ( + g_info.get("environment group") or g_info.get("group") or g_info.get("name") + ) + ret["id"] = ( + g_info.get("environment-id") or g_info.get("group-id") or g_info.get("id") + ) if not ret["group"] and not ret["id"]: raise CommandExecutionError(f"Group '{name}' not found") @@ -2765,7 +2807,14 @@ def group_info(name, expand=False, ignore_groups=None, **kwargs): for pkgtype in pkgtypes: target_found = False for line in salt.utils.itertools.split(out, "\n"): - line = line.strip().lstrip(string.punctuation) + line = line.strip().lstrip(string.punctuation).strip() + # ``member`` is the group member (a package or, for environment + # groups, a subgroup) this line contributes. For an ordinary member + # line it is the line itself; a dnf5 section header (below) carries + # its section's first member inline and overrides it. + member = line + # dnf (yum): the section header sits on its own line, e.g. + # "Mandatory Packages:", with members on the lines that follow. match = re.match( pkgtypes_capturegroup + r" (?:groups|packages):\s*$", line.lower() ) @@ -2778,16 +2827,38 @@ def group_info(name, expand=False, ignore_groups=None, **kwargs): # We've reached the targeted section target_found = True continue + # dnf5: the section header carries this section's first member + # inline, e.g. "Mandatory packages : gettext". + match_dnf5 = re.match( + pkgtypes_capturegroup + r" (?:groups|packages)\s*:\s*(.*?)$", + line.lower(), + ) + if match_dnf5: + if target_found: + # We've reached a new section, break from loop + break + if match_dnf5.group(1) != pkgtype: + continue + # We've reached the targeted section + target_found = True + # Pull the inline first member into its own variable (keeping + # the original case) rather than overwriting ``line``: a header + # with trailing spaces or localized text could otherwise leave + # an empty string and silently drop the member. + first_member = re.match(r"^[^:]+:\s*(.+)$", line) + if first_member is None: + continue + member = first_member.group(1).strip() if target_found: if expand and ret["type"] == "environment group": - if not line or line in completed_groups: + if not member or member in completed_groups: continue log.trace( 'Adding group "%s" to completed list: %s', - line, + member, completed_groups, ) - completed_groups.append(line) + completed_groups.append(member) # The @ prefix disambiguates single-token group ids (e.g. # gnome-desktop) that would otherwise match multiple # groups, but dnf cannot resolve "@" + a multi-word group @@ -2797,17 +2868,20 @@ def group_info(name, expand=False, ignore_groups=None, **kwargs): # name so multi-word member groups still expand (#60276). try: expanded = group_info( - "@" + line, expand=True, ignore_groups=completed_groups + "@" + member, expand=True, ignore_groups=completed_groups ) except CommandExecutionError: expanded = group_info( - line, expand=True, ignore_groups=completed_groups + member, expand=True, ignore_groups=completed_groups ) # Don't shadow the pkgtype variable from the outer loop for p_type in pkgtypes: ret[p_type].update(set(expanded[p_type])) - else: - ret[pkgtype].add(line) + elif member: + # Skip blank lines that fall inside the section (e.g. a + # trailing/blank line before the next header) rather than + # recording an empty package name. + ret[pkgtype].add(member) for pkgtype in pkgtypes: ret[pkgtype] = sorted(ret[pkgtype]) diff --git a/tests/pytests/unit/modules/test_yumpkg.py b/tests/pytests/unit/modules/test_yumpkg.py index fe8f4faf8ed..9e7cace2f39 100644 --- a/tests/pytests/unit/modules/test_yumpkg.py +++ b/tests/pytests/unit/modules/test_yumpkg.py @@ -3580,3 +3580,106 @@ def fake_parse(*args, **kwargs): yumpkg.install("fnord", version=new) call = cmd_mock.mock_calls[0][1][0] assert call == expected_cmd + + +def test_67975_dnf5_group_info(): + """ + Test yumpkg.group_info parsing of the dnf5 'group info' format, where each + package section carries its first member inline after the colon and the + rest on continuation lines. + """ + cmd_out = """\ +Id : libreoffice +Name : LibreOffice +Description : LibreOffice Productivity Suite +Installed : yes +Order : +Langonly : +Uservisible : yes +Repositories : @System +Mandatory packages : libreoffice-calc + : libreoffice-emailmerge + : libreoffice-graphicfilter + : libreoffice-impress + : libreoffice-writer +Optional packages : libreoffice-base + : libreoffice-draw + : libreoffice-math + : libreoffice-pyuno""" + expected = { + "mandatory": [ + "libreoffice-calc", + "libreoffice-emailmerge", + "libreoffice-graphicfilter", + "libreoffice-impress", + "libreoffice-writer", + ], + "optional": [ + "libreoffice-base", + "libreoffice-draw", + "libreoffice-math", + "libreoffice-pyuno", + ], + "default": [], + "conditional": [], + "type": "package group", + "group": "LibreOffice", + "id": "libreoffice", + "description": "LibreOffice Productivity Suite", + } + with patch.object(yumpkg, "_yum", MagicMock(return_value="dnf5")), patch.dict( + yumpkg.__salt__, {"cmd.run_stdout": MagicMock(return_value=cmd_out)} + ): + assert yumpkg.group_info("libreoffice") == expected + + +def test_67975_dnf5_group_list(): + """ + Test yumpkg.group_list parsing of the dnf5 'group list' table. The group + name column is tokenized rather than matched with a regex, so a name that + contains or ends with the word "yes"/"no" (``Just (testing) yes``) is not + mistaken for the trailing Installed column, and a row with trailing + whitespace (``last``) is still classified rather than dropped. + """ + cmd_out = ( + "ID Name Installed\n" + "foo Foo package no\n" + "bar Bar package no\n" + "brackets Just (testing) yes yes\n" + "cleaners Mop and bucket yes\n" + "last But not least no \n" + ) + expected = { + "installed": ["brackets", "cleaners"], + "available": ["foo", "bar", "last"], + "installed environments": [], + "available environments": [], + "available languages": {}, + } + with patch.object(yumpkg, "_yum", MagicMock(return_value="dnf5")), patch.dict( + yumpkg.__salt__, {"cmd.run_stdout": MagicMock(return_value=cmd_out)} + ): + assert yumpkg.group_list() == expected + + +def test_dnf5_group_info_skips_blank_member_lines(): + """ + A blank line inside a package section (e.g. between sections) must not be + recorded as an empty package name. + """ + cmd_out = ( + "Id : development-tools\n" + "Name : Development Tools\n" + "Installed : no\n" + "Mandatory packages : gettext\n" + "\n" + "Optional packages : cmake\n" + ) + with patch.object(yumpkg, "_yum", MagicMock(return_value="dnf5")), patch.dict( + yumpkg.__salt__, {"cmd.run_stdout": MagicMock(return_value=cmd_out)} + ): + info = yumpkg.group_info("Development Tools") + assert info["mandatory"] == ["gettext"] + assert info["optional"] == ["cmake"] + assert "" not in info["mandatory"] + assert "" not in info["optional"] From 4e2a56144d4164ad4900f2e35af912fd51e2881e Mon Sep 17 00:00:00 2001 From: "Gary T. Giesen" Date: Thu, 16 Jul 2026 00:33:20 -0400 Subject: [PATCH 2/2] Drop the spurious empty optional package from the group_info test The empty-member guard added in this PR correctly stops a blank line in the dnf/yum "groupinfo" output from being recorded as an empty-string package, so the existing test_group_info expectation no longer includes the leading "" in the gnome-desktop optional list. --- tests/pytests/unit/modules/test_yumpkg.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/pytests/unit/modules/test_yumpkg.py b/tests/pytests/unit/modules/test_yumpkg.py index 9e7cace2f39..89d0e6e2b89 100644 --- a/tests/pytests/unit/modules/test_yumpkg.py +++ b/tests/pytests/unit/modules/test_yumpkg.py @@ -2944,7 +2944,6 @@ def test_group_info(): "yelp", ], "optional": [ - "", "alacarte", "dconf-editor", "dvgrab",