Source code for meta_package_manager.managers.aptitude

# 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 extra_platforms import UNIX_WITHOUT_MACOS

from ..manager import PackageManager

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

    from ..package import Package


NO_VERSION = "<none>"
"""Placeholder `%v` and `%V` render for a version that does not exist.

An uninstalled package has no current version, and a virtual one has no
candidate. Both print this literal rather than an empty field, so it has to be
mapped back to `None` instead of reaching a package record as a version string.
"""

_ERE_METACHARACTERS = r"\.^$*+?()[]{}|"
"""Characters carrying a special meaning in the POSIX extended regular
expressions aptitude compiles its `~n` and `~d` terms into.

Debian package names are drawn from `[a-z0-9+.-]`, so `+` and `.` are the two
that actually bite: a query for `libsigc++-2.0-0v5` compiles to a regex whose
`++` matches nothing at all, and one for `s.d` quietly matches `sed`.
"""


[docs] def escape_pattern(query: str) -> str: """Render `query` as a regex matching itself and nothing else. Two layers read the string, and each needs a different treatment: - aptitude's *pattern parser* splits a term at whitespace, so a two-word query becomes an implicit `AND` of two terms and matches nothing. A backslash cannot rescue it (the split happens first, leaving a trailing backslash the regex compiler rejects), so whitespace is rewritten as the `[[:space:]]` class, which carries no space to split on. - the *regex compiler* underneath, whose metacharacters are backslashed. Quoting the term instead (`~n"..."`) also survives whitespace, but the quote parser eats backslashes before the regex ever sees them, so the two mechanisms cannot be combined: `~n"^s\\.d$"` still matches `sed`. """ escaped = "".join( f"\\{char}" if char in _ERE_METACHARACTERS else char for char in query ) return re.sub(r"\s+", "[[:space:]]", escaped)
[docs] class Aptitude(PackageManager): """Front-end to Debian's `apt`, with a resolver and a query language of its own. Aptitude reaches the same archives `apt` does, over the same dpkg backend, and is wrapped on the same grounds as {class}`~meta_package_manager.managers.nala.Nala`: 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. ```{note} Every listing is a `search` over aptitude's own pattern language (`~i` for installed, `~U` for upgradable, `~i~g` for installed garbage) rendered through an explicit `--display-format`. That projection is what makes the output a flat `package,version` table instead of the padded columns the interactive interface draws. ``` ```{caution} A query reaches aptitude as a *regular expression*, not as a literal substring, so it is escaped by {func}`escape_pattern` before being spliced into a `~n` or `~d` term. Skipping that step is not a near-miss but a silent empty result: `~n^libsigc++-2.0-0v5$` compiles to a regex matching no package at all. ``` ```{note} `--disable-columns` is redundant on a pipe, aptitude having disabled columns on a redirection since `0.7.5`, and is passed anyway: that heuristic is a moving target (a later release narrowed it again for a caller setting a width explicitly), and the flag is what the manual's own example uses to ask for unformatted output. ``` Documentation: [`aptitude(8)` man page](https://manpages.debian.org/unstable/aptitude/aptitude.8.en.html). """ name = "aptitude" homepage_url = "https://salsa.debian.org/apt-team/aptitude" logo = "debian" platforms = UNIX_WITHOUT_MACOS default_sudo = True """Aptitude reports `are you root?` and exits rather than escalating on its own, so mpm supplies the privilege for the operations that mutate. """ requirement = ">=0.4.11.4" """The release adding `--disable-columns`, announced in aptitude's own `NEWS` as a new `search` option. Every other option and pattern this class passes predates it, and every currently shipped distribution is far above it. """ pre_args = ("--quiet", "--disable-columns") """`--quiet` drops the incremental progress indicators aptitude draws on a terminal; `--disable-columns` asks for unpadded, untruncated output. Source: [`aptitude(8)` options](https://manpages.debian.org/unstable/aptitude/aptitude.8.en.html). """ _INSTALLED_REGEXP = re.compile(r"^(?P<package_id>[^,]+),(?P<installed_version>.+)$") _OUTDATED_REGEXP = re.compile( r"^(?P<package_id>[^,]+),(?P<installed_version>[^,]+),(?P<latest_version>.+)$" ) _SEARCH_REGEXP = re.compile( r"^(?P<package_id>[^,]+),(?P<latest_version>[^,]+),(?P<description>.*)$", # Unlike the two above, this one is walked over the whole output rather # than fed one line at a time by `parse_regex_lines`, so its anchors # need to bind to each line instead of to the string. re.MULTILINE, ) """A comma joins the fields because neither a Debian package name (`[a-z0-9+.-]`) nor a version (`[0-9A-Za-z.+:~-]`) can contain one, while a space appears in every description and an epoch puts a colon in half the versions. It also keeps the disclosed `--display-format` free of spaces, so the command mpm prints can be pasted back into a shell unquoted. Only the description may hold a comma, so it is matched last and greedily. """ version_regexes = (r"aptitude\s+(?P<version>\S+)",) """ ```{code-block} shell-session $ aptitude --version aptitude 0.8.13 Compiler: g++ 14.2.0 Compiled against: apt version 7.0.0 NCurses version 6.5 libsigc++ version: 2.12.1 Gtk+ support disabled. Qt support disabled. Current library versions: NCurses version: ncurses 6.5.20250216 cwidget version: 0.5.18 Apt version: 7.0.0 ``` """ @property def installed(self) -> Iterator[Package]: """Fetch installed packages. ```{code-block} shell-session $ aptitude --quiet --disable-columns search ~i --display-format %p,%v adduser,3.152 apparmor,4.1.0-1 apt,3.0.3 apt-utils,3.0.3 aptitude,0.8.13-7 aptitude-common,0.8.13-7 base-files,13.8+deb13u6 base-passwd,3.6.7 bash,5.2.37-2+b9 bash-completion,1:2.16.0-7 bsdutils,1:2.41-5 busybox,1:1.37.0-6+b8 bzip2,1.0.8-6 ca-certificates,20250419 ``` """ output = self.run_cli("search", "~i", "--display-format", "%p,%v") yield from self.parse_regex_lines(self._INSTALLED_REGEXP, output) @property def outdated(self) -> Iterator[Package]: """Fetch outdated packages. `~U` matches an installed package having a newer candidate, so `%v` and `%V` are both populated and always differ. ```{code-block} shell-session $ aptitude --quiet --disable-columns search ~U --display-format %p,%v,%V bsdutils,1:2.41-5,1:2.41.5-0+deb13u1 eject,2.41-5,2.41.5-0+deb13u1 fdisk,2.41-5,2.41.5-0+deb13u1 libblkid1,2.41-5,2.41.5-0+deb13u1 libexpat1,2.7.1-2,2.8.3-1~deb13u1 libfdisk1,2.41-5,2.41.5-0+deb13u1 liblastlog2-2,2.41-5,2.41.5-0+deb13u1 libmount1,2.41-5,2.41.5-0+deb13u1 libpython3.13-minimal,3.13.5-2+deb13u3,3.13.5-2+deb13u4 libpython3.13-stdlib,3.13.5-2+deb13u3,3.13.5-2+deb13u4 ``` """ output = self.run_cli("search", "~U", "--display-format", "%p,%v,%V") yield from self.parse_regex_lines(self._OUTDATED_REGEXP, output) @property def orphans(self) -> Iterator[Package]: """Fetch packages installed as dependencies that nothing requires anymore. `~g` matches aptitude's garbage set, which also covers packages that are not installed at all, so it is intersected with `~i`. The result is the set `apt autoremove` would remove. ```{code-block} shell-session $ aptitude --quiet --disable-columns search ~i~g --display-format %p,%v nyancat,1.5.2-0.3+b1 ``` """ output = self.run_cli("search", "~i~g", "--display-format", "%p,%v") yield from self.parse_regex_lines(self._INSTALLED_REGEXP, output)
[docs] def search(self, query: str, extended: bool, exact: bool) -> Iterator[Package]: """Fetch matching packages. `~n` matches the name and `~d` the description, so an extended search is the union of the two and a plain one is the name term alone. An exact query anchors that term at both ends. A package with no candidate version renders `%V` as `<none>`: virtual packages match a name search and carry neither a version nor a description. ```{code-block} shell-session $ aptitude --quiet --disable-columns search ~nabc --display-format %p,%V,%d abcde,2.9.3-1,A Better CD Encoder abcl,1.9.2-2,Common Lisp implementation in the Java Virtual Machine abcm2ps,8.14.17-2,Translates ABC music description files to PostScript abcmidi,20250216+ds-1,converter from ABC to MIDI format and back grabc,1.1+git20210125.b9e4316-2+b1,simple program to determine the color string in hex by clicking on a pixel libghc-directory-tree-dev-0.12.1-9dabc,<none>, libghc-directory-tree-prof-0.12.1-9dabc,<none>, node-lodash.kebabcase,<none>, node-types-lodash.kebabcase,<none>, python3-sabctools,8.2.3-2+b4,C implementations of functions for use within SABnzbd ``` ```{code-block} shell-session $ aptitude --quiet --disable-columns search ~n^sed$ --display-format %p,%V,%d sed,4.9-2+deb13u1,GNU stream editor for filtering/transforming text ``` ```{code-block} shell-session $ aptitude --quiet --disable-columns search ~nnyancat|~dnyancat --display-format %p,%V,%d nyancat,1.5.2-0.3+b1,Animated terminal Nyancat nyancat-server,1.5.2-0.3,Animated terminal Nyancat server configurations ``` ```{code-block} shell-session $ aptitude --quiet --disable-columns search ~n^libsigc\\+\\+-2\\.0-0v5$ --display-format %p,%V,%d libsigc++-2.0-0v5,2.12.1-3,type-safe Signal Framework for C++ - runtime ``` """ pattern = escape_pattern(query) if exact: pattern = f"^{pattern}$" term = f"~n{pattern}" # Extended searches are always non-exact, so the description term can # never carry the anchors above. if extended and not exact: term = f"{term}|~d{pattern}" output = self.run_cli("search", term, "--display-format", "%p,%V,%d") for match in self._SEARCH_REGEXP.finditer(output): version = match.group("latest_version") yield self.package( id=match.group("package_id"), description=match.group("description"), latest_version=None if version == NO_VERSION else version, )
[docs] def install(self, package_id: str, version: str | None = None) -> str: """Install one package. ```{code-block} shell-session $ sudo aptitude --quiet --disable-columns --assume-yes install nyancat ``` ```{code-block} shell-session $ sudo aptitude --quiet --disable-columns --assume-yes install nyancat=1.5.2-0.3+b1 ``` """ return self.run_cli( "--assume-yes", "install", f"{package_id}={version}" if version else package_id, sudo=True, )
[docs] def upgrade_all_cli(self) -> tuple[str, ...]: """Generates the CLI to upgrade all outdated packages. `safe-upgrade` never removes an installed package to resolve an upgrade, which is the conservatism `apt upgrade` has and `full-upgrade` drops. ```{code-block} shell-session $ sudo aptitude --quiet --disable-columns --assume-yes safe-upgrade ``` """ return self.build_cli("--assume-yes", "safe-upgrade", sudo=True)
[docs] 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`, which moves an already-installed package to the requested version, since naming a package on `safe-upgrade` still drags in every other member of its dependency cluster. ```{code-block} shell-session $ sudo aptitude --quiet --disable-columns --assume-yes install eject ``` """ return self.build_cli( "--assume-yes", "install", f"{package_id}={version}" if version else package_id, sudo=True, )
[docs] def remove(self, package_id: str) -> str: """Remove one package. ```{code-block} shell-session $ sudo aptitude --quiet --disable-columns --assume-yes remove nyancat ``` """ return self.run_cli("--assume-yes", "remove", package_id, sudo=True)
[docs] def sync(self) -> None: """Sync package metadata. ```{code-block} shell-session $ sudo aptitude --quiet --disable-columns update ``` """ self.run_cli("update", sudo=True)
[docs] def cleanup_orphan(self) -> None: """Remove every package installed as a dependency and no longer required. Aptitude has no `autoremove` verb: the garbage set is named as a pattern on `remove`, the same one {meth}`orphans` reports. ```{code-block} shell-session $ sudo aptitude --quiet --disable-columns --assume-yes remove ~i~g ``` """ self.run_cli("--assume-yes", "remove", "~i~g", sudo=True)
[docs] def cleanup_cache(self) -> None: """Erase the downloaded package files. ```{code-block} shell-session $ sudo aptitude --quiet --disable-columns --assume-yes clean ``` """ self.run_cli("--assume-yes", "clean", sudo=True)