# 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 typing import ClassVar
from extra_platforms import LINUX_LIKE
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
_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.
"""
# 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.
#
# 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.
#
# `upgrade`: 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.
operation_notes: ClassVar = {
"upgrade": (
"The tool's `upgrade` takes no package argument, so a single "
"upgrade runs through `install`, which moves an installed package "
"to its candidate."
),
}
name = "Nala"
documentation_url = "https://gitlab.com/volian/nala/-/wikis/home"
repository_url = "https://gitlab.com/volian/nala"
logo = "debian"
platforms = LINUX_LIKE
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.
"""
privileged_operations = frozenset(
{"cleanup", "install", "remove", "sync", "upgrade", "upgrade_all"},
)
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.
Nala drops its colouring when its output is not a terminal, with one
leak: the branch of a package that carries no description is emitted
with an italic sequence even through a pipe. Every capture reaches this
parser through {meth}`~meta_package_manager.execution.CLIExecutor.run`,
which strips ANSI sequences before returning it.
"""
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 line in output.splitlines():
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 mark_explicit(self, package_id: str) -> str:
"""Mark an installed package as manually installed.
`nala install` returns early for a package already at its newest
version, before it touches the automatic flag:
[`install.py`](https://gitlab.com/volian/nala/-/blob/7784856980039ecb5f1a4449fd03dd569889ec60/nala/install.py#L1086-1099).
nala has no command to change that flag, so this goes through the
`apt-mark` sibling of the apt suite nala depends on.
```{code-block} shell-session
$ sudo apt-mark manual firefox
```
"""
return self.run_cli(
"manual",
package_id,
override_cli_path=self.sibling_cli("apt-mark"),
sudo=True,
)
[docs]
def upgrade_all_cli(self) -> tuple[str, ...]:
"""Generates the CLI to upgrade all outdated 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 provided package.
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:
"""Remove one 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:
"""Remove every package 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)