Source code for meta_package_manager.managers.zypper

# 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 itertools import groupby
from operator import itemgetter
from typing import ClassVar
from xml.etree import ElementTree

from extra_platforms import UNIX_WITHOUT_MACOS

from ..capabilities import version_not_implemented
from ..manager import PackageManager
from ..version import parse_version

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

    from ..package import Package
    from ..version import TokenizedString

    class SearchResult(TypedDict):
        """One package of the XML search, merged across repositories and archs."""

        id: str
        installed_version: TokenizedString | None
        latest_version: TokenizedString


[docs] class Zypper(PackageManager): """openSUSE's package manager. `mpm` drives zypper in XML mode (`--xmlout`) and parses the result with the standard library's `xml.etree.ElementTree`: the most stable machine-readable output zypper offers. Every call is pinned with `--no-color` and `--no-abbrev` (untruncated columns), `--non-interactive` for unattended runs, and `--no-cd --no-refresh` so it never touches removable media or auto-refreshes metadata (`mpm` refreshes explicitly through `sync`). Command equivalences with other managers are listed in [Pacman/Rosetta](https://wiki.archlinux.org/title/Pacman/Rosetta). """ # Both `installed` and `search` run `search --details --type package`: # `--details` is the only mode exposing versions, but it returns one row # per source package, architecture and past release. `mpm` drops # `other-version` rows and keeps the highest edition per package name to # collapse those duplicates. # # `zypper install` leaves a package that is already installed and up to # date marked as automatically installed, and zypper has no command that # changes only that mark. So `mpm cleanup --orphans` can still remove a # package that `mpm install` named while zypper held it as a dependency. # See SolverRequester.cc: # https://github.com/openSUSE/zypper/blob/697f8809b2c6c0104cdb20fe4bb1fa048d8227ab/src/SolverRequester.cc#L679-L695 operation_notes: ClassVar = { "installed": ( "The `--details` listing returns one row per source package, " "architecture and past release; duplicates are collapsed to the " "highest edition per name." ), "search": ( "The `--details` results return one row per source package and " "past release; duplicates are collapsed to the highest edition " "per name." ), "install": ( "Installing an already-installed package re-marks it as " "automatically installed, so `cleanup --orphans` can later " "remove what `mpm install` named." ), } name = "openSUSE Zypper" homepage_url = "https://en.opensuse.org/Portal:Zypper" documentation_url = "https://documentation.suse.com/smart/systems-management/html/concept-zypper/index.html" repository_url = "https://github.com/openSUSE/zypper" wikipedia_url = "https://en.wikipedia.org/wiki/Zypper" logo = "opensuse" keywords = ("opensuse", "suse") platforms = UNIX_WITHOUT_MACOS default_sudo = True privileged_operations = frozenset( {"cleanup", "install", "remove", "sync", "upgrade", "upgrade_all"}, ) requirement = ">=1.14.0" pre_args = ( "--no-color", "--no-abbrev", "--non-interactive", "--no-cd", "--no-refresh", ) version_regexes = (r"zypper\s+(?P<version>\S+)",) """ ```{code-block} shell-session $ zypper --version zypper 1.14.11 ``` """ def _search(self, *args: str) -> Iterator[SearchResult]: """Utility method to parse and interpret results of the `zypper search` command. This is reused by the `installed` and `search` operations. The query below stands in for the `*args` each caller appends, and its result shows both shapes the note above describes. `libopenh264-8` comes back twice, once `installed` and once `other-version`: the `installed` row gives its installed version, and the highest edition of all its rows gives its latest version. `libopenh264-devel` comes back once per repository at a different edition, which `mpm` collapses to the highest. ```{code-block} shell-session $ zypper --no-color --no-abbrev --non-interactive --no-cd --no-refresh \ --xmlout search --details --type package libopenh264 <?xml version='1.0'?> <stream> <message type="info">Ignoring repository &apos;openSUSE-20260830-0&apos; because of &apos;no-cd&apos; option.</message> <message type="info">Loading repository data...</message> <message type="info">Reading installed packages...</message> <search-result version="0.0"> <solvable-list> <solvable status="installed" name="libopenh264-8" kind="package" edition="2.6.0-2.suse1699.10" arch="aarch64" repository="Open H.264 Codec (openSUSE Tumbleweed)"/> <solvable status="other-version" name="libopenh264-8" kind="package" edition="2.6.0~noopenh264-1.5" arch="aarch64" repository="Main Repository (OSS)"/> <solvable status="not-installed" name="libopenh264-devel" kind="package" edition="2.6.0-2.suse1699.10" arch="aarch64" repository="Open H.264 Codec (openSUSE Tumbleweed)"/> <solvable status="not-installed" name="libopenh264-devel" kind="package" edition="2.6.0~noopenh264-1.5" arch="aarch64" repository="Main Repository (OSS)"/> </solvable-list> </search-result> </stream> ``` """ # noqa: E501 output = self.run_cli( "--xmlout", "search", # --details is the only option that is providing the package's version... "--details", # ...but comes with duplicate results due to source packages, different arch # and old releases. So we filters them out to only keep proper packages. "--type", "package", # Additional search arguments. *args, must_succeed=True, auto_extra_args=True, ) if not output: return # Group packages by ID. key_func = itemgetter("name") solvables = sorted( ( solvable.attrib for solvable in ElementTree.fromstring(output).iterfind( "search-result/solvable-list/solvable", ) ), key=key_func, ) # An `other-version` row is another release of a package installed at a # different version, so it counts toward the latest version only. Each # version is the highest among all repositories and arch variations. for key, group in groupby(solvables, key_func): rows = tuple(group) installed = [ parse_version(row["edition"]) for row in rows if row.get("status") == "installed" ] yield { "id": key, "installed_version": max(installed, default=None), "latest_version": max(parse_version(row["edition"]) for row in rows), } @property def installed(self) -> Iterator[Package]: """Fetch installed packages. ```{code-block} shell-session $ zypper --no-color --no-abbrev --non-interactive --no-cd --no-refresh \ --xmlout search --details --type package --installed-only ``` """ for package in self._search("--installed-only"): yield self.package( id=package["id"], installed_version=package["installed_version"] ) @property def outdated(self) -> Iterator[Package]: """Fetch outdated packages. ```{code-block} shell-session $ zypper --no-color --no-abbrev --non-interactive --no-cd --no-refresh \ --xmlout list-updates <?xml version='1.0'?> <stream> <message type="info">Ignoring repository &apos;openSUSE-20260830-0&apos; because of &apos;no-cd&apos; option.</message> <message type="info">Loading repository data...</message> <message type="info">Reading installed packages...</message> <update-status version="0.6"> <update-list> <update kind="package" name="libopenh264-8" edition="2.6.0-2.suse1699.10" arch="aarch64" edition-old="2.6.0~noopenh264-1.5"><summary>H.264 codec library</summary><description>OpenH264 is a codec library which supports H.264 encoding and decoding. It is suitable for use in real time applications such as WebRTC. This package contains libraries used by applications that use openh264.</description><license/><source url="http://codecs.opensuse.org/openh264/openSUSE_Tumbleweed" alias="repo-openh264"/></update></update-list> </update-status> </stream> ``` """ # noqa: E501 output = self.run_cli("--xmlout", "list-updates", must_succeed=True) if not output: return updates = ElementTree.fromstring(output).iterfind( "update-status/update-list/update", ) for update in updates: yield self.package( id=update.attrib["name"], # An empty `<description/>` reads as no description. description=(update.findtext("description") or "").strip() or None, latest_version=update.attrib["edition"], installed_version=update.attrib["edition-old"], ) _ORPHANS_REGEXP = re.compile( r"^i\+?\s+\|[^|]+\|\s*(?P<package_id>\S+)\s*\|\s*(?P<installed_version>\S+)" r"\s*\|\s*(?P<arch>\S+)", re.MULTILINE, ) """Extract the installed rows of `zypper packages`' plain-text table. `packages --unneeded` has no XML rendering, so unlike the other queries this one parses the human-readable table, whose columns are `status`, `repository`, `name`, `version` and `arch`. Only the rows flagged installed (`i` or `i+`) are kept. """ @property def orphans(self) -> Iterator[Package]: """Fetch packages installed as dependencies that nothing requires anymore. ```{code-block} shell-session $ zypper --no-color --no-abbrev --non-interactive --no-cd --no-refresh \ packages --unneeded Ignoring repository 'openSUSE-20260830-0' because of 'no-cd' option. Loading repository data... Reading installed packages... S | Repository | Name | Version | Arch ---+-----------------------+---------+-----------+-------- i | Main Repository (OSS) | xorriso | 1.5.8-1.2 | aarch64 ``` """ output = self.run_cli("packages", "--unneeded") yield from self.parse_regex_lines(self._ORPHANS_REGEXP, output)
[docs] def search(self, query: str, extended: bool, exact: bool) -> Iterator[Package]: """Fetch matching packages. ```{code-block} shell-session $ zypper --no-color --no-abbrev --non-interactive --no-cd --no-refresh \ --xmlout search --details --type package kopete ``` ```{code-block} shell-session $ zypper --no-color --no-abbrev --non-interactive --no-cd --no-refresh \ --xmlout search --details --type package --search-description kopete ``` ```{code-block} shell-session $ zypper --no-color --no-abbrev --non-interactive --no-cd --no-refresh \ --xmlout search --details --type package --match-exact kopete ``` ```{code-block} shell-session $ zypper --no-color --no-abbrev --non-interactive --no-cd --no-refresh \ --xmlout search --details --type package --search-description \ --match-exact kopete ``` """ search_param = [] if extended: search_param.append("--search-description") if exact: search_param.append("--match-exact") for package in self._search(*search_param, query): yield self.package( id=package["id"], latest_version=package["latest_version"] )
[docs] @version_not_implemented def install(self, package_id: str, version: str | None = None) -> str: """Install one package. ```{code-block} shell-session $ sudo zypper --no-color --no-abbrev --non-interactive --no-cd \ --no-refresh install kopete ``` """ return self.run_cli("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 zypper --no-color --no-abbrev --non-interactive --no-cd \ --no-refresh update ``` """ return self.build_cli("update", 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 zypper --no-color --no-abbrev --non-interactive --no-cd \ --no-refresh update kopete ``` """ return self.build_cli("update", package_id, sudo=True)
[docs] def remove(self, package_id: str) -> str: """Remove one package. ```{code-block} shell-session $ sudo zypper --no-color --no-abbrev --non-interactive --no-cd \ --no-refresh remove kopete ``` """ return self.run_cli("remove", package_id, sudo=True)
[docs] def remove_orphan(self, package_id: str) -> str: """Remove one package, dropping dependencies it alone pulled in. `--clean-deps` additionally removes the dependencies that were installed with the package and are no longer needed. ```{code-block} shell-session $ sudo zypper --no-color --no-abbrev --non-interactive --no-cd \ --no-refresh remove --clean-deps kopete ``` """ return self.run_cli("remove", "--clean-deps", package_id, sudo=True)
[docs] def sync(self) -> None: """Sync package metadata. ```{code-block} shell-session $ sudo zypper --no-color --no-abbrev --non-interactive --no-cd \ --no-refresh refresh ``` """ self.run_cli("refresh", sudo=True)
[docs] def cleanup_cache(self) -> None: """Removes things we don't need anymore. ```{code-block} shell-session $ sudo zypper --no-color --no-abbrev --non-interactive --no-cd \ --no-refresh clean ``` """ self.run_cli("clean", sudo=True)