# Copyright Kevin Deldycke <kevin@deldycke.com> 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
from itertools import groupby
from operator import itemgetter
from typing import Iterator, TypedDict
import xmltodict
from extra_platforms import UNIX_WITHOUT_MACOS
from meta_package_manager.base import Package, PackageManager
from meta_package_manager.capabilities import version_not_implemented
from meta_package_manager.version import TokenizedString, parse_version
[docs]
class Zypper(PackageManager):
"""Zypper package manager.
Documentation:
- https://en.opensuse.org/Portal:Zypper
- https://documentation.suse.com/smart/linux/html/concept-zypper/index.html
- https://opensuse.github.io/openSUSE-docs-revamped-temp/zypper/
See other command equivalences at: https://wiki.archlinux.org/title/Pacman/Rosetta
"""
homepage_url = "https://en.opensuse.org/Portal:Zypper"
platforms = UNIX_WITHOUT_MACOS
requirement = "1.14.0"
pre_args = (
"--no-color",
"--no-abbrev",
"--non-interactive",
"--no-cd",
"--no-refresh",
)
version_regex = r"zypper\s+(?P<version>\S+)"
"""
.. code-block:: shell-session
► zypper --version
zypper 1.14.11
"""
[docs]
class SearchResult(TypedDict):
id: str
version: TokenizedString
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.
.. code-block:: shell-session
► zypper --no-color --no-abbrev --non-interactive --no-cd --no-refresh \
--xmlout search --details --type package [*args]
<?xml version='1.0'?>
<stream>
<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="aaa_base" kind="package"
edition="12.12-bp12.3.1" arch="x86_64"/>
<solvable status="installed" name="adwaita-icon-theme"
kind="package" edition="1.0.3-bp153.1.1" arch="x86_64"/>
<solvable status="other-version" name="adwaita-icon-theme"
kind="package" edition="1.0.1-bp153.1.1" arch="x86_64"/>
<solvable status="not-installed" name="kopete-devel"
kind="package" edition="20.04.2-bp153.2.5.1" arch="x86_64"
repository="Update"/>
<solvable status="not-installed" name="kopete-devel"
kind="package" edition="20.04.2-bp153.2.2.1" arch="x86_64"
repository="Update"/>
<solvable status="not-installed" name="kopete-devel"
kind="package" edition="20.04.2-bp153.2.2.1" arch="x86_64"
repository="Debug"/>
<solvable status="not-installed" name="kopete-devel"
kind="package" edition="20.04.2-bp153.2.5.1" arch="i586"
repository="Update"/>
<solvable status="not-installed" name="kopete-devel"
kind="package" edition="20.04.2-bp153.2.2.1" arch="i586"
repository="Update"/>
(...)
</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,
)
if not output:
return
package_list = (
xmltodict.parse(output)
.get("stream", {})
.get("search-result", {})
.get("solvable-list", {})
.get("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">Loading repository data...</message>
<message type="info">Reading installed packages...</message>
<update-status version="0.6">
<update-list>
<update name="git" kind="package" edition="2.34.1-10.9.1"
edition-old="2.26.2-3.34.1" arch="x86_64">
<summary>Fast, scalable revision control system</summary>
<description>
Blah blah blah...
</description>
<license/>
<source
url="http://download.opensuse.org/updata/leap/15.3/sle"
alias="repo-sle-update"/>
</update>
(...)
</update-list>
</update-status>
</stream>
"""
output = self.run_cli("--xmlout", "list-updates")
if not output:
return
package_list = []
update_list = (
xmltodict.parse(output)
.get("stream", {})
.get("update-status", {})
.get("update-list", {})
)
if update_list:
package_list = update_list.get("update", [])
for package in package_list:
yield self.package(
id=package["@name"],
description=package.get("description"),
latest_version=package["@edition"],
installed_version=package["@edition-old"],
)
[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"])
@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 packages (default) or only the one provided
as parameter.
.. code-block:: shell-session
► sudo zypper --no-color --no-abbrev --non-interactive --no-cd \
--no-refresh update
"""
return self.build_cli("update", sudo=True)
@version_not_implemented
def upgrade_one_cli(
self,
package_id: str,
version: str | None = None,
) -> tuple[str, ...]:
"""Generates the CLI to upgrade all packages (default) or only the one provided
as parameter.
.. 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 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(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)