# 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
import xmltodict
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 Any, TypedDict
from ..package import Package
from ..version import TokenizedString
class SearchResult(TypedDict):
"""One XML search match, reduced to the fields the dedup logic keys on."""
id: str
version: TokenizedString
def _xml_items(parent: Any, key: str) -> list[Any]:
"""Return the `key` children of `parent` as a list, whatever their number.
`xmltodict` renders a repeated element as a list, collapses a lone
occurrence to the bare mapping, and maps an empty parent to `None`. A caller
iterating the raw value walks the children in the first case, the *attribute
names* of the single child in the second, and raises on the third. zypper
reaches every shape from its ordinary output: `search --match-exact` matches
exactly one solvable, and a host that is one package behind reports exactly
one update.
"""
children = parent.get(key) if parent else None
if children is None:
return []
if isinstance(children, list):
return children
return [children]
[docs]
class Zypper(PackageManager):
"""openSUSE's package manager.
`mpm` drives zypper in XML mode (`--xmlout`) and parses the result with
`xmltodict`: 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`).
```{note}
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.
```
Documentation:
- [Concept guide](https://documentation.suse.com/smart/systems-management/html/concept-zypper/index.html)
- [Command equivalences with other managers](https://wiki.archlinux.org/title/Pacman/Rosetta)
"""
name = "openSUSE Zypper"
homepage_url = "https://en.opensuse.org/Portal:Zypper"
logo = "opensuse"
keywords = ("opensuse", "suse")
platforms = UNIX_WITHOUT_MACOS
default_sudo = True
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`
returned twice, once `installed` and once `other-version`, which `mpm`
drops; and `libopenh264-devel` returned 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 'openSUSE-20260830-0' because of 'no-cd' 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>
```
"""
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,
)
if not output:
return
package_list = _xml_items(
xmltodict
.parse(output)
.get("stream", {})
.get("search-result", {})
.get("solvable-list", {}),
"solvable",
)
# Group packages by ID.
key_func = itemgetter("@name")
# Skip old packages reported in the results as 'other-version'.
fresh_packages = sorted(
(p for p in package_list if p.get("@status") != "other-version"),
key=key_func,
)
# Returns the highest version for a package ID among all repositories and
# arch variations.
for key, group in groupby(fresh_packages, key_func):
yield {
"id": key,
"version": max(parse_version(p["@edition"]) for p in group),
}
@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["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 'openSUSE-20260830-0' because of 'no-cd' 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>
```
"""
output = self.run_cli("--xmlout", "list-updates", must_succeed=True)
if not output:
return
update_list = (
xmltodict
.parse(output)
.get("stream", {})
.get("update-status", {})
.get("update-list", {})
)
for package in _xml_items(update_list, "update"):
yield self.package(
id=package["@name"],
description=package.get("description"),
latest_version=package["@edition"],
installed_version=package["@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"], installed_version=package["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)