# 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 atexit
import os
import re
import shutil
import tempfile
from datetime import datetime, timezone
from functools import cached_property
from importlib import resources
from pathlib import Path
from extra_platforms import LINUX_LIKE, MACOS, UNIX_WITHOUT_MACOS
from ..capabilities import search_capabilities, version_not_implemented
from ..manager import COOLDOWN_EXEMPT, PackageManager
from ..version import VersionRange
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
from click_extra.envvar import TEnvVars
from extra_platforms import Group, Platform
from ..package import Package
_YAY_COOLDOWN_INIT_LUA = (
resources
.files("meta_package_manager.managers")
.joinpath("yay_cooldown.lua")
.read_text(encoding="UTF-8")
)
"""Lua policy mpm drops into the overlay's `init.lua` to express the release-age
{attr}`cooldown <meta_package_manager.execution.CLIExecutor.cooldown>` for yay.
The policy itself lives in the sibling `yay_cooldown.lua` file and is read here
through {mod}`importlib.resources` so it stays editable and syntax-highlightable as
real Lua. Static on purpose: the only per-run input is the `MPM_COOLDOWN_EPOCH`
environment variable, so the file never has to be regenerated. It registers two hooks
(both keyed off the same cutoff) and first chains the user's real `init.lua` so
nothing in their config is lost. See {meth}`Yay.cooldown_env` for how it is
delivered.
"""
[docs]
class Pacman(PackageManager):
"""Arch Linux's native package manager, covering the official repositories.
`mpm` forces `--noconfirm` and `--color never` on every call so pacman
runs unattended and prints uncolored text the regexes can parse. Installed
packages come from `--query` and upgradable ones from
`--query --upgrades`; searches hit the sync databases via
`--sync --search`.
The `Pacaur`, `Paru` and `Yay` subclasses are AUR helpers that reuse
every parser and forced argument here unchanged, overriding only the binary
(and, for `yay`, adding a release-age cooldown).
Command equivalences with other managers are listed in
[Pacman/Rosetta](https://wiki.archlinux.org/title/Pacman/Rosetta).
```{caution}
`--query --upgrades` only reports updates for packages tracked in a
sync database, so foreign packages (installed with `pacman -U`, as AUR
helpers do) stay invisible to the base `pacman` binary. `Pacaur`, `Paru`
and `Yay` escape this because their own binary also queries the AUR RPC,
which is verified for `yay`: see {meth}`Pacman.outdated`. `Aura` does not
escape it and reports the two halves separately.
```
"""
name = "Arch Linux pacman"
homepage_url = "https://wiki.archlinux.org/title/pacman"
logo: str | None = "archlinux"
"""Annotated so a subclass may drop the mark: `DkpPacman` is a pacman fork
that Arch's logo would misattribute.
"""
keywords = ("arch",)
platforms: frozenset[Platform] | Group | Platform | Iterable[Platform | Group] = (
UNIX_WITHOUT_MACOS
)
"""Annotated with the base class's own union so a subclass may widen it:
`DkpPacman` ships for macOS too.
"""
default_sudo = True
requirement = ">=5.0.0"
pre_args: tuple[str, ...] = ("--noconfirm", "--color", "never")
"""Annotated with a variadic tuple so a subclass may drop an argument:
`Aura` rejects `--color` outright and forces `--noconfirm` alone.
"""
_INSTALLED_REGEXP = re.compile(r"(?P<package_id>\S+) (?P<installed_version>\S+)")
_OUTDATED_REGEXP = re.compile(
r"(?P<package_id>\S+) (?P<installed_version>\S+) -> (?P<latest_version>\S+)"
)
_SEARCH_REGEXP = re.compile(
r"(?P<repo_id>\S+?)/(?P<package_id>\S+)\s+(?P<version>\S+).*\n\s+(?P<description>.+)",
re.MULTILINE | re.VERBOSE,
)
version_regexes = (r".*Pacman\s+v(?P<version>\S+)",)
r"""Search version right after the `Pacman ` string.
```{code-block} shell-session
$ pacman --version
.--. Pacman v6.0.1 - libalpm v13.0.1
/ _.-' .-. .-. .-. Copyright (C) 2006-2021 Pacman Development Team
\ '-. '-' '-' '-' Copyright (C) 2002-2006 Judd Vinet
'--'
This program may be freely redistributed under
the terms of the GNU General Public License.
```
"""
@property
def installed(self) -> Iterator[Package]:
"""Fetch installed packages.
```{code-block} shell-session
$ pacman --noconfirm --color never --query
a52dec 0.7.4-11
aalib 1.4rc5-14
abseil-cpp 20211102.0-2
accountsservice 22.08.8-2
acl 2.3.1-2
acme.sh 3.0.2-1
acpi 1.7-3
acpid 2.0.33-1
```
"""
output = self.run_cli("--query")
yield from self.parse_regex_lines(self._INSTALLED_REGEXP, output)
@property
def outdated(self) -> Iterator[Package]:
"""Fetch outdated packages.
```{code-block} shell-session
$ pacman --noconfirm --color never --query --upgrades
linux 4.19.1.arch1-1 -> 4.19.2.arch1-1
linux-headers 4.19.1.arch1-1 -> 4.19.2.arch1-1
```
:::{note}
`pacman --query --upgrades` (`-Qu`) only reports updates for
packages tracked in a sync database (official repos, plus any
local repo configured in `pacman.conf`). Foreign packages, those
installed with `pacman -U` as most AUR helpers do, are invisible
to `-Qu` and surface only under `-Qm`.
The `Pacaur`, `Paru` and `Yay` subclasses inherit this method
verbatim, yet still see AUR updates because their own binary's
`-Qu` additionally queries the AUR RPC for foreign packages. The
per-subclass binary override is therefore load-bearing: routing
these helpers through `pacman` directly would silently drop every
AUR update from the results.
```{caution}
Confirmed on a live Arch box against `yay` 13.0.1: with an AUR
package deliberately downgraded, `yay --query --upgrades` reported it
alongside the repository upgrades in one listing, so the inherited
parser sees both. `Aura` is the exception and overrides this, its
`-Qu` reporting the repositories alone.
```
:::
"""
output = self.run_cli("--query", "--upgrades")
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.
Same `<name> <version>` listing shape as {meth}`~meta_package_manager.manager.PackageManager.installed`, narrowed
by `--deps --unrequired` (`-Qtd`) to the orphan set.
```{code-block} shell-session
$ pacman --noconfirm --color never --query --deps --unrequired
gtest 1.14.0-1
libwlroots 0.16.2-2
```
"""
output = self.run_cli("--query", "--deps", "--unrequired")
yield from self.parse_regex_lines(self._INSTALLED_REGEXP, output)
[docs]
@search_capabilities(extended_support=False)
def search(self, query: str, extended: bool, exact: bool) -> Iterator[Package]:
"""Fetch matching packages.
```{caution}
Search does not supports extended matching.
```
```{code-block} shell-session
$ pacman --noconfirm --color never --sync --search fire
extra/dump_syms 0.0.7-1
Symbol dumper for Firefox
extra/firefox 99.0-1
Standalone web browser from mozilla.org
extra/firefox-i18n-ach 99.0-1
Acholi language pack for Firefox
extra/firefox-i18n-af 99.0-1
Afrikaans language pack for Firefox
extra/firefox-i18n-an 99.0-1
Aragonese language pack for Firefox
extra/firefox-i18n-ar 99.0-1
Arabic language pack for Firefox
extra/firefox-i18n-ast 99.0-1
Asturian language pack for Firefox
```
"""
if exact:
query = f"^{query}$"
output = self.run_cli("--sync", "--search", query)
for _repo_id, package_id, version, description in self._SEARCH_REGEXP.findall(
output,
):
yield self.package(
id=package_id,
description=description,
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 pacman --noconfirm --color never --sync firefox
```
"""
return self.run_cli("--sync", package_id, sudo=True)
[docs]
def upgrade_all_cli(self) -> tuple[str, ...]:
"""Generates the CLI to upgrade the package provided as parameter.
```{code-block} shell-session
$ sudo pacman --noconfirm --color never --sync --refresh --sysupgrade
```
"""
return self.build_cli("--sync", "--refresh", "--sysupgrade", 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.
```{code-block} shell-session
$ sudo pacman --noconfirm --color never --sync firefox
```
"""
return self.build_cli("--sync", package_id, sudo=True)
[docs]
def remove(self, package_id: str) -> str:
"""Removes a package.
```{code-block} shell-session
$ sudo pacman --noconfirm --color never --remove firefox
```
"""
return self.run_cli("--remove", package_id, sudo=True)
[docs]
def remove_orphan(self, package_id: str) -> str:
"""Remove a package together with its now-orphaned dependencies.
`--recursive` (`-s`) additionally removes the dependencies the
package pulled in that no other installed package needs.
```{code-block} shell-session
$ sudo pacman --noconfirm --color never --remove --recursive firefox
```
"""
return self.run_cli("--remove", "--recursive", package_id, sudo=True)
[docs]
def sync(self) -> None:
"""Sync package metadata.
```{code-block} shell-session
$ sudo pacman --noconfirm --color never --sync --refresh
```
"""
self.run_cli("--sync", "--refresh", sudo=True)
[docs]
def cleanup_cache(self) -> None:
"""Removes things we don't need anymore.
```{code-block} shell-session
$ sudo pacman --noconfirm --color never --sync --clean --clean
```
"""
self.run_cli("--sync", "--clean", "--clean", sudo=True)
[docs]
def doctor_cli(self) -> tuple[str, ...]:
"""Generates the CLI running the native self-diagnosis.
`--database --check` (`-Dk`) verifies the consistency of the local
package database, silent and exit-`0` when everything is fine.
```{code-block} shell-session
$ pacman --noconfirm --color never --database --check
```
"""
return self.build_cli("--database", "--check")
[docs]
class Aura(Pacman):
"""AUR helper wrapping `pacman`, driven through the `aura` binary.
Aura reuses every parser and query of `Pacman` unchanged, but not its
forced arguments and not its {meth}`~meta_package_manager.manager.PackageManager.outdated`: those are the two places
where a v4 aura genuinely differs from the helpers around it.
```{note}
Every v4 subcommand answers to a long name, a pacman letter and a long
pacman flag alike, so `aura --query --upgrades` and `aura -Qu` are one
command. That is what lets this class inherit `Pacman`'s operations
verbatim rather than restating them against a clap-only vocabulary.
```
```{important}
Repository upgrades and AUR upgrades are two commands here, where `yay` and
`paru` fold both into one `--query --upgrades`. Aura's `-Qu` reads the
local ALPM database against the sync databases, so it reports the official
repositories alone and an out-of-date AUR package stays invisible to it.
The AUR half lives behind `--aursync --sysupgrade --dryrun`, which reports
without acting. {meth}`~meta_package_manager.manager.PackageManager.outdated` runs both and concatenates them, so mpm
reports what an `aura -Syu` followed by an `aura -Au` would actually
upgrade.
```
Documentation: [Aura manual](https://fosskers.github.io/aura/).
"""
name = "Arch Linux aura"
homepage_url = "https://github.com/fosskers/aura"
logo = "archlinux"
default_sudo = False
"""Aura refuses to build as root, `makepkg` hard-refusing it, and elevates
itself for the privileged half instead.
Its `[general] elevator` setting names the program, and with none set it
looks for `sudo`, `doas` and `run0` in that order. So the escalation default
inherited from `Pacman` must not wrap it, the same treatment `yay` gets.
"""
internal_sudo = True
"""Aura calls the elevation binary from inside its own commands."""
requirement = ">=4.0.0"
"""The major that introduced the subcommand interface every operation here
drives; the parsers were checked against `4.2.0`.
Aura v3 was a different implementation in a different language, and its
releases are not on this line at all.
"""
pre_args = ("--noconfirm",)
"""`Pacman`'s `--color never` is dropped: aura rejects the flag outright
with `error: unexpected argument '--color' found`, which would fail every
call rather than merely leaving the output colored.
Nothing replaces it, and nothing needs to: aura writes no SGR escapes when
its output is not a terminal, verified on every listing parsed here.
"""
version_regexes = (r"aura\s+(?P<version>\S+)",)
r"""Search the version right after the `aura` string.
`Pacman`'s own pattern looks for `Pacman v`, which aura never prints, so the
probe would leave the manager permanently unavailable without this.
```{code-block} shell-session
$ aura --version
aura 4.2.0
```
"""
_AUR_OUTDATED_REGEXP = re.compile(
r"^\s*(?P<package_id>\S+)\s+::\s+"
r"(?P<installed_version>\S+)\s+->\s+(?P<latest_version>\S+)"
)
"""The AUR upgrade report keeps neither the shape nor the separator of the
repository one: the row is indented and the package is split from its
versions by ` :: ` where `Pacman`'s `_OUTDATED_REGEXP` expects a bare space.
Reusing that pattern here would not fail loudly, which is why this one
exists: it would match from the separator onwards and report a package
literally named `::`.
"""
@property
def outdated(self) -> Iterator[Package]:
"""Fetch outdated packages, from the repositories and the AUR alike.
The two halves are separate commands and cannot be folded: see the
class docstring.
```{code-block} shell-session
$ aura --noconfirm --query --upgrades
kmscon-terminfo 10.0.2-2 -> 10.0.3-1
python-platformdirs 4.11.5-1 -> 4.11.7-1
```
```{code-block} shell-session
$ aura --noconfirm --aursync --sysupgrade --dryrun
yay-bin :: 12.6.0-1 -> 13.0.1-1
```
"""
yield from super().outdated
output = self.run_cli("--aursync", "--sysupgrade", "--dryrun")
yield from self.parse_regex_lines(self._AUR_OUTDATED_REGEXP, output)
[docs]
class DkpPacman(Pacman):
"""devkitPro's `pacman` fork, covering the console homebrew toolchains.
devkitPro ships its own pacman build under the `dkp-pacman` name so it can
sit beside a distribution's own `pacman` without colliding, pointed at the
devkitPro repositories holding the devkitARM, devkitA64 and devkitPPC
toolchains and the libraries built against them.
Every operation, parser and forced argument is inherited from `Pacman`
unchanged: the fork tracks upstream closely enough that its version banner
still comes from the same `printf(" .--. Pacman v%s - libalpm v%s")`
call, so the inherited {attr}`Pacman.version_regexes` reads it as-is.
Unlike the AUR helpers below this one is not a helper at all but pacman
itself, so it keeps the `default_sudo` inherited from `Pacman`.
Documentation: [devkitPro pacman](https://github.com/devkitPro/pacman).
"""
id = "dkp-pacman"
name = "devkitPro pacman"
homepage_url = "https://github.com/devkitPro/pacman"
logo = None
"""No mark of its own, and Arch's would misattribute a devkitPro tool: the
manager page keeps the default package glyph instead.
"""
platforms = LINUX_LIKE, MACOS
"""devkitPro publishes `dkp-pacman` for Linux and for macOS, the latter
through the `devkitpro-pacman-installer.pkg` of its releases. Its Windows
path installs the toolchains through MSYS2's own `pacman` instead, which is
a different binary this manager does not claim.
"""
requirement = ">=6.0.0"
"""The series aligned with upstream pacman 6, which every parser inherited
here was written against. devkitPro's own `v1.0.x` releases of 2020 predate
that alignment and are excluded deliberately.
"""
cli_names = ("dkp-pacman",)
"""The binary is deliberately prefixed upstream so it never shadows a
distribution's own `pacman`; the class name would otherwise resolve to
`dkppacman`.
"""
[docs]
class Pacaur(Pacman):
"""AUR helper wrapping `pacman`, driven through the `pacaur` binary.
Inherits every operation, parser and forced argument from `Pacman`; only
the binary and version probe differ. Routing through `pacaur` is what lets
`--query --upgrades` report AUR updates on top of the official repositories.
Unlike `pacman`, the helper must run as the regular user: it aborts under
root (`you cannot perform this operation as root`) because `makepkg`
refuses to build as root, and it invokes `sudo pacman` itself for the
privileged steps. `mpm` therefore never wraps it in `sudo`.
"""
unmaintained = True
unmaintained_message = (
"The [original pacaur repository is archived]"
"(https://github.com/rmarquis/pacaur) (last commit 2018) and the "
"[E5ten fork](https://github.com/E5ten/pacaur) has had no commits since 2021; "
"migrate to `paru` or `yay`."
)
name = "Arch Linux pacaur"
homepage_url = "https://github.com/E5ten/pacaur"
logo = "archlinux"
default_sudo = False
"""pacaur aborts its sync-class operations under root and runs `sudo pacman`
itself, so the escalation default inherited from `Pacman` must not wrap it.
"""
internal_sudo = True
"""pacaur calls `sudo pacman` from inside its own commands for the install,
upgrade and removal steps.
"""
requirement = ">=4.0.0"
version_regexes = (r"pacaur\s+(?P<version>\S+)",)
r"""Search version right after the `pacaur` string.
```{code-block} shell-session
$ pacaur --version
pacaur 4.8.6
```
"""
[docs]
class Paru(Pacman):
"""AUR helper wrapping `pacman`, driven through the `paru` binary.
Inherits every operation, parser and forced argument from `Pacman`; only
the binary and version probe differ. Its own `--query --upgrades` reports
AUR updates on top of the official repositories. The `>=1.9.3` floor is the
first `paru` release to implement `--sysupgrade`, the flag the inherited
`upgrade_all_cli` builds.
Unlike `pacman`, the helper must run as the regular user: any transaction
building AUR packages aborts under root (`can't install AUR package as
root`), and paru invokes `sudo pacman` itself for the privileged steps.
`mpm` therefore never wraps it in `sudo`.
"""
name = "Arch Linux paru"
homepage_url = "https://github.com/Morganamilo/paru"
logo = "archlinux"
default_sudo = False
"""paru refuses to build AUR packages under root and runs `sudo pacman`
itself, so the escalation default inherited from `Pacman` must not wrap it.
"""
internal_sudo = True
"""paru calls `sudo` from inside its own commands (the `Sudo`/`SudoFlags`
settings of `paru.conf`) for the install, upgrade and removal steps.
"""
# v1.9.3 is the first version implementing the --sysupgrade option.
requirement = ">=1.9.3"
version_regexes = (r"paru\s+v(?P<version>\S+)",)
r"""Search version right after the `paru` string.
```{code-block} shell-session
$ paru --version
paru v1.10.0 - libalpm v13.0.1
```
"""
_AUR_INFO_ENV = {"LC_ALL": "C.UTF-8", "TZ": "UTC"}
"""Locale and timezone forced on the release-date probe below.
paru localizes the `Repository` and `Last Modified` field labels and
renders the timestamp in the host's local timezone with no offset marker
(`src/fmt.rs` formats `%a, %e %b %Y %T` after a `with_timezone(&Local)`),
so the probe pins both to parse a stable English UTC rendering.
"""
_REPOSITORY_REGEXP = re.compile(r"^Repository\s*:\s*(?P<repo>\S+)", re.MULTILINE)
_LAST_MODIFIED_REGEXP = re.compile(
r"^Last Modified\s*:\s*(?P<date>.+?)\s*$", re.MULTILINE
)
[docs]
def release_date(self, package_id: str) -> datetime | None:
"""Publication timestamp of the latest release of an AUR package.
`paru --sync --info` prints the AUR RPC's `LastModified` for an AUR
package: the server-set timestamp of the last push, the same clock
`mpm`'s yay overlay gates on. Git commit dates are client-set and
forgeable, and are never consulted.
A package answering from an official repository instead
(`Repository` is anything but `aur`) is out of the gate's scope:
Arch's archive stages releases on its own, so it reads as
{data}`~meta_package_manager.manager.COOLDOWN_EXEMPT` and always
passes.
```{code-block} console
$ paru --noconfirm --color never --sync --info paru
Repository : aur
Name : paru
Version : 2.1.0-1
Description : Feature packed AUR helper
URL : https://github.com/morganamilo/paru
Licenses : GPL-3.0-or-later
Maintainer : Morganamilo
Votes : 1289
Popularity : 21.161366
First Submitted : Wed, 21 Oct 2020 20:07:31
Last Modified : Sat, 12 Jul 2025 14:52:15
Out Of Date : No
```
"""
output = self.run_cli(
"--sync",
"--info",
package_id,
override_extra_env=self._AUR_INFO_ENV,
)
repository = self._REPOSITORY_REGEXP.search(output)
if repository and repository.group("repo") != "aur":
return COOLDOWN_EXEMPT
match = self._LAST_MODIFIED_REGEXP.search(output)
if match:
# paru space-pads single-digit days (`%e`): collapse runs of
# whitespace so `strptime`'s `%d` reads both renderings.
raw_date = " ".join(match.group("date").split())
try:
parsed = datetime.strptime(raw_date, "%a, %d %b %Y %H:%M:%S")
except ValueError:
return None
# The probe forced TZ=UTC, so the naive rendering is UTC.
return parsed.replace(tzinfo=timezone.utc)
return None
[docs]
def upgrade_all_cli_excluding(
self,
package_ids: tuple[str, ...],
) -> tuple[str, ...]:
"""Full upgrade in one transaction, skipping the named packages.
pacman's `--ignore` takes a comma-separated list, so the held packages
ride the helper's own `--sysupgrade` transaction: dependency ordering
and the repo-plus-AUR interleaving stay paru's job.
```{code-block} console
$ paru --noconfirm --color never --sync --refresh --sysupgrade \\
--ignore=fig,kiwi
```
"""
return self.build_cli(
"--sync",
"--refresh",
"--sysupgrade",
f"--ignore={','.join(package_ids)}",
sudo=True,
)
[docs]
class Pikaur(Pacman):
"""AUR helper wrapping `pacman`, driven through the `pikaur` binary.
Inherits every operation, parser and forced argument from `Pacman`; the
binary, the version probe and the release floor are what differ. Its own
`--query --upgrades` reports AUR updates on top of the official
repositories.
Like the other helpers, pikaur must run as the regular user: `makepkg`
refuses to build as root, and pikaur drives `sudo pacman` itself for the
privileged steps. `mpm` therefore never wraps it in `sudo`.
```{note}
pikaur wraps pacman's options faithfully except `--sync --refresh
--sysupgrade` (`-Syu`), which it splits into a refresh pass and an upgrade
pass so a user can amend the package selection in between. The inherited
{meth}`Pacman.upgrade_all_cli` still builds the combined form, and the
`--noconfirm` forced by {attr}`Pacman.pre_args` is what keeps that split
unattended.
```
Documentation: [pikaur](https://github.com/actionless/pikaur).
"""
name = "Arch Linux pikaur"
homepage_url = "https://github.com/actionless/pikaur"
logo = "archlinux"
default_sudo = False
"""pikaur builds AUR packages through `makepkg`, which hard-refuses to run
as root, and calls `sudo pacman` itself, so the escalation default inherited
from `Pacman` must not wrap it.
"""
internal_sudo = True
"""pikaur calls `sudo pacman` from inside its own commands for the install,
upgrade and removal steps.
"""
requirement = ">=1.0.0"
"""pikaur versions independently of pacman, so the inherited `>=5.0.0`
would reject every release it has ever made.
The floor sits at the start of the `1.x` series because nothing this class
relies on is newer than it: the wrapped pacman option set and the `Pikaur v`
version banner both predate it, and the parsers are pacman's own.
"""
version_regexes = (r".*Pikaur\s+v(?P<version>\S+)",)
r"""Search version right after the `Pikaur ` string.
Anchoring on `Pikaur` rather than the inherited `Pacman` pattern is
load-bearing: pikaur reports *both* versions, embedding the second line of
`pacman --version` in its own output, so the inherited regex would silently
report the version of pacman instead.
```{code-block} console
$ pikaur --version
Pikaur v1.33.3
Pacman v6.0.2 - libalpm v13.0.2 - pyalpm v0.10.6
```
The real banner side-joins those lines with an ASCII-art mascot, which is
why the block above is an illustration rather than a harvested fixture: the
`.*` prefix is what absorbs the art. Both forms are emitted by the same
`print_version()` of `pikaur/print_department.py`, the quiet one verbatim.
"""
[docs]
class Trizen(Pacman):
"""AUR helper wrapping `pacman`, driven through the `trizen` binary.
Inherits every operation, parser and forced argument from `Pacman`; the
binary, the version probe and the release floor are what differ. Its own
`--query --upgrades` reports AUR updates on top of the official
repositories.
Like the other helpers, trizen must run as the regular user: `makepkg`
refuses to build as root, and trizen calls `sudo pacman` itself for the
privileged steps. `mpm` therefore never wraps it in `sudo`.
```{note}
Upstream is slow rather than stopped: commits continue, but `1.68` of
December 2022 is still the newest release. It stays unflagged here because
the stability policy keys `unmaintained` on an abandoned upstream, not on a
quiet release cadence.
```
Documentation: [trizen](https://github.com/trizen/trizen).
"""
name = "Arch Linux trizen"
homepage_url = "https://github.com/trizen/trizen"
logo = "archlinux"
default_sudo = False
"""trizen builds AUR packages through `makepkg`, which hard-refuses to run
as root, and calls `sudo pacman` itself, so the escalation default inherited
from `Pacman` must not wrap it.
"""
internal_sudo = True
"""trizen calls `sudo pacman` from inside its own commands for the install,
upgrade and removal steps.
"""
requirement = ">=1.0.0"
"""trizen versions independently of pacman, so the inherited `>=5.0.0`
would reject every release it has ever made. Nothing this class relies on is
newer than the `1.x` series: the parsers are pacman's own.
"""
version_regexes = (r"trizen\s+(?P<version>\S+)",)
r"""Search version right after the `trizen` string.
```{code-block} shell-session
$ trizen --version
trizen 1.68
```
"""
[docs]
class Yay(Pacman):
"""AUR helper wrapping `pacman`, driven through the `yay` binary.
Inherits every operation, parser and forced argument from `Pacman`; the
binary, version probe and the release-age cooldown below are what differ. Its
own `--query --upgrades` reports AUR updates on top of the official
repositories.
Unlike `pacman`, the helper must run as the regular user: yay warns under
root (`Avoid running yay as root/sudo.`) and any AUR build then dies in
`makepkg`, which refuses to run as root. yay drives `sudo` itself for the
privileged steps (its `--sudo`, `--sudoflags` and `--sudoloop` options),
so `mpm` never wraps it in `sudo`. That also keeps the injected
`XDG_CONFIG_HOME` cooldown overlay below visible to yay, where a `sudo`
wrap would have reset the environment.
```{note}
yay exposes no release-age flag, so mpm enforces the supply-chain
{attr}`cooldown <meta_package_manager.execution.CLIExecutor.cooldown>` by
overlaying a generated `init.lua` through a private `XDG_CONFIG_HOME` (see
{meth}`Yay.cooldown_env`). This needs yay >= 13.0.0, when the Lua
`UpgradeSelect`/`AURPreInstall` hooks landed; an older yay stays a usable
manager but cannot honor a cooldown. The upstream request for a less invasive
injection point is [Jguer/yay#2883](https://github.com/Jguer/yay/issues/2883).
```
"""
name = "Arch Linux yay"
homepage_url = "https://github.com/Jguer/yay"
logo = "archlinux"
default_sudo = False
"""yay discourages root runs (`makepkg` hard-refuses them for AUR builds) and
drives `sudo` itself, so the escalation default inherited from `Pacman` must
not wrap it. A wrap would also strip the `XDG_CONFIG_HOME` cooldown overlay
through `sudo`'s environment reset.
"""
internal_sudo = True
"""yay calls the escalation binary from inside its own commands, configurable
through its `--sudo`, `--sudoflags` and `--sudoloop` options.
"""
requirement = ">=11.0.0"
cooldown_env_var = "XDG_CONFIG_HOME"
"""yay reads no release-age option of its own, so mpm repurposes `XDG_CONFIG_HOME`
to point yay at the throwaway config overlay built by {meth}`cooldown_env`.
Unlike the single-value variables of pip/uv/npm, the value is a *directory*; the
cutoff itself rides alongside it in `MPM_COOLDOWN_EPOCH`. Set so the structural
`supports_cooldown` check (and the `--cooldown` help text) still recognize yay
as cooldown-capable.
"""
version_regexes = (r"yay\s+v(?P<version>\S+)",)
r"""Search version right after the `yay` string.
```{code-block} shell-session
$ yay --version
yay v11.1.2 - libalpm v13.0.1
```
"""
cooldown_requirement = ">=13.0.0"
"""Minimum yay version whose Lua hooks the cooldown overlay relies on.
[v13.0.0](https://github.com/Jguer/yay/releases/tag/v13.0.0) introduced
`yay.create_autocmd` and the `UpgradeSelect`/`AURPreInstall` events. Kept
apart from {attr}`requirement` (`>=11.0.0`) so a v11/v12 yay stays fully
usable for everything except the cooldown.
"""
_resolving_cooldown_env = False
"""Re-entrancy guard for {meth}`cooldown_env`.
Held while {meth}`cooldown_env` resolves {attr}`supports_cooldown`, whose
{attr}`version <meta_package_manager.execution.CLIExecutor.version>` lookup runs
`yay --version` through {meth}`~meta_package_manager.execution.CLIExecutor.run`, which calls straight back into
{meth}`cooldown_env`. The nested call returns early so the probe runs without a
cooldown env instead of recursing until the stack overflows.
"""
@property
def supports_cooldown(self) -> bool:
"""Whether this yay can natively enforce a release-age cooldown.
Reports the structural capability while idle (`cooldown is None`) so the
import-time `COOLDOWN_SUPPORTED_MANAGERS` help text stays I/O-free, and only
probes the manager
{attr}`version <meta_package_manager.execution.CLIExecutor.version>` once a
cooldown is active, gating on {attr}`cooldown_requirement`. A yay older than
that (or undetectable) reports no support, so the fail-closed default skips
install/upgrade rather than running them unguarded.
"""
if self.cooldown is None:
return self.cooldown_env_var is not None
if self.version is None:
return False
return self.version in VersionRange(self.cooldown_requirement)
[docs]
def cooldown_env(self) -> TEnvVars:
"""Deliver the release-age cooldown through a private `XDG_CONFIG_HOME`.
yay has no release-age option, so rather than injecting a single value mpm
points yay at `_cooldown_overlay_dir`: a throwaway config tree whose
generated `init.lua` (`_YAY_COOLDOWN_INIT_LUA`) registers the
cooldown Lua hooks. The cutoff travels as `MPM_COOLDOWN_EPOCH` (Unix seconds
of `now - cooldown`), keeping the `init.lua` asset static, and
`MPM_YAY_USER_DIR` lets it chain the user's real config so the redirect stays
lossless.
Returns an empty mapping when no cooldown is set or the installed yay predates
the Lua hooks (see {attr}`supports_cooldown`).
"""
if self.cooldown is None:
return {}
# Resolving `supports_cooldown` reads `version`, which runs `yay --version`
# through `run()`, re-entering this method. Break that loop: a version probe
# needs no cooldown env, and the outer call finishes once `version` is cached.
if self._resolving_cooldown_env:
return {}
self._resolving_cooldown_env = True
try:
if not self.supports_cooldown:
return {}
cutoff = datetime.now(tz=timezone.utc) - self.cooldown
# Clamp to the Unix epoch: a cooldown reaching before 1970 yields a
# negative timestamp, and yay's Lua (gopher-lua) parses that back to nil,
# which silently drops the gate. Epoch 0 keeps the floor effective (every
# real release post-dates 1970, so all are held back, as an overlong
# cooldown intends).
epoch = max(0, int(cutoff.timestamp()))
env = {
"XDG_CONFIG_HOME": str(self._cooldown_overlay_dir),
"MPM_COOLDOWN_EPOCH": str(epoch),
}
user_dir = self._user_yay_config_dir()
if user_dir is not None:
env["MPM_YAY_USER_DIR"] = str(user_dir)
return env
finally:
self._resolving_cooldown_env = False
@staticmethod
def _user_yay_config_dir() -> Path | None:
"""Resolve the user's real yay config directory using yay's own precedence.
Mirrors `GetConfigPath`/`GetLuaConfigPath` in yay's
`pkg/settings/dirs.go`: `$XDG_CONFIG_HOME/yay` wins over
`$HOME/.config/yay`. Returns `None` when neither variable is set, matching
yay falling back to its built-in defaults.
```{caution}
Read from {data}`os.environ` *before* mpm overrides `XDG_CONFIG_HOME`
for the child, so it resolves the user's genuine directory, not the overlay.
```
"""
xdg_config = os.environ.get("XDG_CONFIG_HOME")
if xdg_config:
return Path(xdg_config) / "yay"
home = os.environ.get("HOME")
if home:
return Path(home) / ".config" / "yay"
return None
@cached_property
def _cooldown_overlay_dir(self) -> Path:
"""Materialize the private config tree mpm points yay at for the cooldown.
Built once per manager instance and removed at interpreter exit. The tree holds
two entries under `<root>/yay/`:
- `init.lua`: the static `_YAY_COOLDOWN_INIT_LUA` policy.
- `config.json`: a symlink to the user's real config, so the
`XDG_CONFIG_HOME` redirect stays lossless. yay's `init.lua` only
*overlays* `config.json`; it does not replace it, so the user's settings are
preserved.
Only those two paths are placed here because they are the sole files yay derives
from `XDG_CONFIG_HOME` (per `dirs.go`); its cache, build dir and
`vcs.json` follow `XDG_CACHE_HOME`/`HOME` and are untouched by the
redirect.
```{warning}
Cleanup is registered with {func}`atexit`, **not**
`weakref.finalize(self, ...)`. The overlay must outlive every yay
subprocess that reads it, and yay re-reads `init.lua` mid-run (it re-execs
during an install that pulls dependencies). Tying removal to this instance's
garbage collection raced that re-read: if the manager was collected after
{meth}`cooldown_env` but before yay finished, the overlay vanished and
the gate silently failed *open*: the worst outcome for a supply-chain
control. Process-lifetime cleanup is a safe upper bound; the tree is tiny.
```
"""
root = Path(tempfile.mkdtemp(prefix="mpm-yay-cooldown-"))
atexit.register(shutil.rmtree, root, ignore_errors=True)
config_dir = root / "yay"
config_dir.mkdir(parents=True, exist_ok=True)
(config_dir / "init.lua").write_text(_YAY_COOLDOWN_INIT_LUA, encoding="UTF-8")
user_dir = self._user_yay_config_dir()
if user_dir is not None:
user_config = user_dir / "config.json"
if user_config.is_file():
(config_dir / "config.json").symlink_to(user_config)
return root