Source code for meta_package_manager.managers.nala

# 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

import re
from typing import ClassVar

from extra_platforms import LINUX

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


_ANSI_REGEXP = re.compile(r"\x1b\[[0-9;]*m")
"""Style sequences to strip before parsing.

Nala drops its colouring when its output is not a terminal, with one leak: the
branch describing a package that carries no description interpolates an italic
sequence directly instead of going through the helper that checks for a
terminal, so it is emitted even through a pipe.
"""

_BRANCH_PREFIXES = ("+--", "`--", "├──", "└──")
"""Openings of the two lines continuing a record, whichever glyphs are in play.

`LC_ALL=C` selects the ASCII pair, but the Unicode ones are matched too so a
record is never mistaken for a new one should that lever ever fail.
"""


[docs] class Nala(PackageManager): """Front-end to Debian's `apt`, driving `libapt-pkg` directly. Nala reaches the same archives `apt` does, adding a parallel downloader, a mirror-scoring fetcher and a transaction history it can roll back. It is wrapped on the same grounds as the AUR helpers that sit over `pacman`: a distinct tool with a vocabulary of its own, rather than a translation layer over another CLI. It shares dpkg's lock with the rest of that family, so mpm runs it serially against them. ```{important} Every invocation forces `LC_ALL=C`, and it is doing two jobs at once. Nala translates its own output at runtime, so a French host reports `est installé` where the parsers expect `is installed`; and it picks its tree glyphs from the encoding of its output stream. The same flag that pins the language to the untranslated strings also selects the ASCII glyphs, giving one stable shape to parse instead of a matrix of locale and encoding. ``` ```{caution} A listing is a record of three lines, not a line per package: a header naming the package and its version, a branch giving its status, and another carrying its description. That is why this is a class rather than a declarative definition, and why `outdated` correlates two lines to pair an installed version with the candidate it can move to. ``` ```{note} Nala's own `upgrade` takes no package arguments at all, accepting only exclusions, so naming a package cannot restrict it. Upgrading one package therefore goes through `install`, which moves an already-installed package to its candidate version, exactly as `apt` does. ``` Documentation: [nala](https://gitlab.com/volian/nala). """ name = "Nala" homepage_url = "https://gitlab.com/volian/nala" logo = "debian" platforms = LINUX default_sudo = True """Nala checks for root and exits with a message rather than escalating on its own, so mpm supplies the privilege for the operations that mutate. """ requirement = ">=0.12.2" """The oldest release still shipped by a supported distribution, and the floor from which the listing format is unchanged: its emitting code is byte-identical from there through the newest release. """ extra_env: ClassVar = { # Pins the language to the untranslated strings the parsers below # expect, and selects the ASCII tree glyphs at the same time. "LC_ALL": "C", } version_regexes = (r"nala\s+(?P<version>\S+)",) r"""Search the version right after the `nala` string. ```{code-block} shell-session $ nala --version nala 0.16.0 ``` """ _HEADER_REGEXP = re.compile( r"^(?P<package_id>\S+)\s+(?P<installed_version>\S+)(?:\s+\[[^\]]*\])?\s*$", ) """A record's first line: the package, its version, and an optional bracket naming the archive it came from. Anchored at both ends so a short description cannot be read as a package and a version. """ _UPGRADABLE_REGEXP = re.compile(r"upgradable to\s+(?P<latest_version>\S+)") """The candidate version, named on the status branch of a record when the package has one to move to. """ def _parse_records(self, output: str) -> Iterator[tuple[str, str, str | None]]: """Yield `(package_id, installed_version, latest_version)` per record. A record spans a header and the branches below it, so the header is held while its branches are read, and emitted once the record closes. """ package_id: str | None = None installed_version: str | None = None latest_version: str | None = None def flush() -> Iterator[tuple[str, str, str | None]]: if package_id and installed_version: yield package_id, installed_version, latest_version for raw_line in output.splitlines(): line = _ANSI_REGEXP.sub("", raw_line) stripped = line.strip() if stripped.startswith(_BRANCH_PREFIXES): match = self._UPGRADABLE_REGEXP.search(stripped) if match: latest_version = match.group("latest_version") continue # Anything else closes the record being read, blank line or not. yield from flush() package_id = installed_version = latest_version = None header = self._HEADER_REGEXP.match(line) if header: package_id = header.group("package_id") installed_version = header.group("installed_version") yield from flush() @property def installed(self) -> Iterator[Package]: """Fetch installed packages. ```{code-block} shell-session $ nala list --installed ``` ```{code-block} console vim 2:8.2.3995-1+b2 [Debian/sid main] +-- is installed `-- Vi IMproved - enhanced vi editor ``` """ output = self.run_cli("list", "--installed") for package_id, installed_version, _latest in self._parse_records(output): yield self.package(id=package_id, installed_version=installed_version) @property def outdated(self) -> Iterator[Package]: """Fetch outdated packages. The two versions sit on different lines: the header carries the installed one, and the status branch names the candidate. ```{code-block} shell-session $ nala list --upgradable ``` ```{code-block} console vim 2:8.2.3995-1+b2 [Debian/sid main] +-- is installed and upgradable to 2:8.2.4659-1 `-- Vi IMproved - enhanced vi editor ``` """ output = self.run_cli("list", "--upgradable") for package_id, installed_version, latest_version in self._parse_records( output, ): if not latest_version: continue yield self.package( id=package_id, installed_version=installed_version, latest_version=latest_version, )
[docs] @search_capabilities(extended_support=False, exact_support=False) def search(self, query: str, extended: bool, exact: bool) -> Iterator[Package]: """Fetch matching packages. The query is a regular expression as far as nala is concerned, so mpm's own refiltering narrows whatever comes back. ```{caution} Search does not support extended or exact matching. ``` ```{code-block} shell-session $ nala search vim ``` """ output = self.run_cli("search", query) for package_id, version, _latest in self._parse_records(output): yield self.package(id=package_id, latest_version=version)
[docs] @version_not_implemented def install(self, package_id: str, version: str | None = None) -> str: """Install one package. ```{code-block} shell-session $ sudo nala install --assume-yes firefox ``` """ return self.run_cli("install", "--assume-yes", package_id, sudo=True)
[docs] def upgrade_all_cli(self) -> tuple[str, ...]: """Generates the CLI to upgrade all packages. ```{code-block} shell-session $ sudo nala upgrade --assume-yes ``` """ return self.build_cli("upgrade", "--assume-yes", 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 package provided as parameter. Routed through `install`, since nala's own `upgrade` accepts no package arguments and would upgrade everything. Installing a package already present moves it to its candidate version. ```{code-block} shell-session $ sudo nala install --assume-yes firefox ``` """ return self.build_cli("install", "--assume-yes", package_id, sudo=True)
[docs] def remove(self, package_id: str) -> str: """Removes a package. ```{code-block} shell-session $ sudo nala remove --assume-yes firefox ``` """ return self.run_cli("remove", "--assume-yes", package_id, sudo=True)
[docs] def sync(self) -> None: """Sync package metadata. ```{code-block} shell-session $ sudo nala update ``` """ self.run_cli("update", sudo=True)
[docs] def cleanup_orphan(self) -> None: """Removes packages nothing depends on anymore. ```{code-block} shell-session $ sudo nala autoremove --assume-yes ``` """ self.run_cli("autoremove", "--assume-yes", sudo=True)
[docs] def cleanup_cache(self) -> None: """Removes things we don't need anymore. ```{code-block} shell-session $ sudo nala clean ``` """ self.run_cli("clean", sudo=True)