#!/usr/bin/env python3
# <xbar.title>Meta Package Manager</xbar.title>
# <xbar.version>8.0.0.dev0</xbar.version>
# <xbar.author>Kevin Deldycke</xbar.author>
# <xbar.author.github>kdeldycke</xbar.author.github>
# <xbar.desc>List outdated packages and manage upgrades.</xbar.desc>
# <xbar.dependencies>python,mpm</xbar.dependencies>
# <xbar.image>https://raw.githubusercontent.com/kdeldycke/meta-package-manager/refs/heads/main/docs/assets/xbar-submenu-table-rendering.png</xbar.image>
# <xbar.abouturl>https://mpm.run/bar-plugin/</xbar.abouturl>
# XXX Quotes around default values are required by SwiftBar, and optional in Xbar, which
# strips them. Unquoted, the variable is silently ignored by SwiftBar and never reaches
# its settings UI.
# <xbar.var>boolean(VAR_GROUP_BY_MANAGER="false"): Group each manager's packages into a section of its own.</xbar.var>
# <xbar.var>boolean(VAR_TABLE_RENDERING="true"): Aligns package names and versions in a table for easier visual parsing.</xbar.var>
# <xbar.var>number(VAR_MAX_VERSION_WIDTH="18"): Widest a version renders in a menu line, in characters. Longer ones are shortened with an ellipsis.</xbar.var>
# XXX Font options are declared SwiftBar-only, as Xbar truncates a default value at its
# first `=` character. See: https://github.com/matryer/xbar/issues/832
# <swiftbar.var>string(VAR_DEFAULT_FONT=""): Font parameters for regular text.</swiftbar.var>
# <swiftbar.var>string(VAR_MONOSPACE_FONT="font=Menlo size=12"): Font parameters for monospace text. Used for table rendering and error messages.</swiftbar.var>
# XXX Only SwiftBar hides a plugin producing no output, so this is SwiftBar-only too.
# <swiftbar.var>boolean(VAR_HIDE_WHEN_UP_TO_DATE="false"): Hide the menu bar icon while no package is outdated and no manager reports an error.</swiftbar.var>
"""SwiftBar and Xbar plugin for Meta Package Manager (the {command}`mpm` CLI).
Default update cycle should be set to several hours so we have a chance to get
user's attention once a day. Higher frequency might ruin the system as all
checks are quite resource intensive, and Homebrew might hit GitHub's API calls
quota.
- [Xbar automatically bridge plugin options](https://xbarapp.com/docs/2021/03/14/variables-in-xbar.html) between its UI
and environment variable on script execution.
- This is [in progress for SwiftBar](https://github.com/swiftbar/SwiftBar/issues/160).
"""
from __future__ import annotations
import argparse
import os
import re
import sys
from configparser import RawConfigParser
from functools import cached_property
from operator import itemgetter, methodcaller
from pathlib import Path
from shlex import shlex
from shutil import which
from subprocess import run
from textwrap import dedent
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Generator
SWIFTBAR_MIN_VERSION = (2, 1, 0)
"""SwiftBar `2.1.0` fixes an issue with multiple parameters in the font strings.
The fix was first handed out as a `2.1.2`-labelled test build, a number that never
reached a release: the public train renumbered it down to `2.1.0`. Requiring the
build we validated on would lock the plugin out of every released SwiftBar.
See [swiftbar/SwiftBar#445](https://github.com/swiftbar/SwiftBar/issues/445).
"""
XBAR_MIN_VERSION = (2, 1, 7)
"""Xbar v2.1.7-beta is the latest version available on Homebrew."""
MPM_MIN_VERSION = (5, 0, 0)
"""Mpm v5.0.0 was the first version taking care of the complete layout rendering."""
INSTALL_ARGV = ("uv", "tool", "install", "--upgrade", "meta-package-manager")
"""Bootstrap command offered when no runnable `mpm` is found.
A global [`uv tool`](https://docs.astral.sh/uv/concepts/tools/) install, the
primary method of the [installation page](https://mpm.run/install/): it puts
`mpm` on the `PATH` of every shell, where a `pip install` would have buried it in
whichever interpreter happened to run this plugin. `--upgrade` makes the same
command serve the outdated-`mpm` case, so nothing pins the specifier to
{data}`MPM_MIN_VERSION`: the latest release always satisfies it, and Xbar mangles
a quoted `>=` specifier anyway (see
[matryer/xbar#831](https://github.com/matryer/xbar/issues/831)).
The GNOME Shell extension offers the same command from its own missing-`mpm`
menu, and `tests/test_gnome_extension.py` holds the two in sync.
"""
INSTALL_DOCS_URL = "https://mpm.run/install/"
"""Installation page, offered beside {data}`INSTALL_ARGV`.
`uv` may itself be missing, and is not the right answer everywhere: a
distribution package, Homebrew or a standalone binary all install `mpm` too.
"""
PLUGIN_DOCS_URL = "https://mpm.run/bar-plugin/"
"""Documentation of this plugin, linked from the About submenu.
The same address the `<xbar.abouturl>` header hands the host, which only
surfaces it in its own plugin browser and never in the menu.
"""
MPM_TIMEOUT = 60
"""Maximum duration in seconds the plugin lets any single `mpm` call run.
Passed as `--timeout` to every `mpm` invocation so the plugin is never at the
mercy of mpm's own per-operation defaults, which are tuned for interactive CLI use
and far too long for a background menubar refresh (120s for read-only queries, 500s
for state-changing operations like `sync`). A wedged package manager then fails
the whole refresh in a minute instead of freezing the menubar for several.
"""
[docs]
class MPMPlugin:
"""Implements the minimal code necessary to locate and call the `mpm` CLI on the
system.
Once `mpm` is located, we can rely on it to produce the main output of the plugin.
The output must supports both [Xbar dialect](https://github.com/matryer/xbar-plugins/blob/main/CONTRIBUTING.md#plugin-api)
and [SwiftBar dialect](https://github.com/swiftbar/SwiftBar#plugin-api).
"""
[docs]
@staticmethod
def getenv_str(var, default: str | None = None) -> str | None:
"""Utility to get environment variables.
Note that all environment variables are strings. Always returns a lowered-case
string.
"""
value = os.environ.get(var, None)
if value is None:
return default
return str(value).lower()
[docs]
@staticmethod
def getenv_bool(var, default: bool = False) -> bool:
"""Utility to normalize boolean environment variables.
Relies on [`configparser.RawConfigParser.BOOLEAN_STATES`](https://github.com/python/cpython/blob/3c298e2e385fc6f462abaada2fd680deb1a2b58e/Lib/configparser.py#L596-L597)
to translate strings into boolean.
"""
value = MPMPlugin.getenv_str(var)
if value is None:
return default
return RawConfigParser.BOOLEAN_STATES[value]
[docs]
@staticmethod
def getenv_int(var, default: int) -> int:
"""Utility to normalize integer environment variables.
Falls back to the default on anything that is not a number, so a typo in
a plugin setting degrades the layout instead of killing the menu.
"""
value = MPMPlugin.getenv_str(var)
if value is None:
return default
try:
return int(value)
except ValueError:
return default
[docs]
@staticmethod
def normalize_params(font_string: str, valid_ids: set[str] | None = None) -> str:
"""Parse a multi-parameters string and return a normalized string.
The string is expected to be a space-separated list of parameters, each
parameter being a key/value pair separated by an equal sign.
Only keeps the parameters that are in the `valid_ids` set and ignores the
rest. By default, only `color`, `font` and `size` are kept.
Multiple values for the same parameter will be deduplicated, and the last one
will be kept.
Available parameters are documented by both hosts:
- [SwiftBar](https://github.com/swiftbar/SwiftBar?tab=readme-ov-file#parameters)
- [Xbar](https://github.com/matryer/xbar-plugins/blob/main/CONTRIBUTING.md#parameters)
"""
if not valid_ids:
valid_ids = {"color", "font", "size"}
params = {}
key = None
previous_token_is_separator = False
for token in shlex(font_string):
# Flag the token as a separator if it is an equal sign.
if token == "=":
previous_token_is_separator = True
# Token positioned just after an equal sign is a value. Let's attach it to
# the key and store it in the params dictionary.
elif previous_token_is_separator:
if key and key in valid_ids:
params[key] = token
# Reset the flag and key.
previous_token_is_separator = False
key = None
# Any token is considered a potential key until we find an equal sign.
else:
key = token
return " ".join(f"{k}={v}" for k, v in params.items())
[docs]
@staticmethod
def str_to_version(version_string: str | None) -> tuple[int, ...]:
"""Transforms a string into a tuple of integers representing a version."""
if not version_string:
return ()
return tuple(map(int, version_string.strip().split(".")))
[docs]
@staticmethod
def version_to_str(version_tuple: tuple[int, ...] | None) -> str:
"""Transforms a tuple of integers representing a version into a string."""
if not version_tuple:
return "None"
return ".".join(map(str, version_tuple))
[docs]
@cached_property
def table_rendering(self) -> bool:
"""Aligns package names and versions, like a table, for easier visual parsing.
If `True`, will aligns all items using a fixed-width font.
"""
return self.getenv_bool("VAR_TABLE_RENDERING", True)
[docs]
@cached_property
def plugin_version(self) -> str:
"""Version this script advertises to its host.
Read back from the `<xbar.version>` header rather than kept in a
constant beside it: that header is the one place the number is
written, both hosts parse it out of the source, and `bump-my-version`
rewrites it on release. A second copy is a second thing to drift.
"""
try:
source = Path(__file__).read_text(encoding="UTF-8")
except OSError:
return "unknown"
match = re.search(r"<xbar\.version>(?P<version>[^<]+)</xbar\.version>", source)
return match.group("version") if match else "unknown"
[docs]
@cached_property
def hide_when_up_to_date(self) -> bool:
"""Remove the menu bar icon entirely while there is nothing to report.
SwiftBar hides a plugin whose run produces no output, so rendering
nothing is how the icon is made to disappear. That forces the plugin to
tell a deliberate silence from a broken `mpm` call, which is why
{meth}`print_menu` only tolerates an empty output when this is set.
Xbar has no such behavior, hence the SwiftBar-only declaration.
Value is sourced from the `VAR_HIDE_WHEN_UP_TO_DATE` environment variable.
"""
return self.getenv_bool("VAR_HIDE_WHEN_UP_TO_DATE", False)
[docs]
@cached_property
def default_font(self) -> str:
"""Make it easier to change font, sizes and colors of the output."""
return self.normalize_params(
self.getenv_str("VAR_DEFAULT_FONT", ""), # type: ignore
)
[docs]
@cached_property
def monospace_font(self) -> str:
"""Make it easier to change font, sizes and colors of the output."""
return self.normalize_params(
self.getenv_str("VAR_MONOSPACE_FONT", "font=Menlo size=12"), # type: ignore
)
[docs]
@cached_property
def error_font(self) -> str:
"""Error font is the monospace font, in red.
It carries no size of its own. A smaller string is still laid out in a
row the menu sizes for the larger font, and both hosts leave the
surplus under the text instead of splitting it: at `size=10`, an error
line ended up with 15 pixels of space below it where every other row
leaves 9.
"""
return self.normalize_params(f"{self.monospace_font} color=red")
[docs]
@cached_property
def is_swiftbar(self) -> bool:
"""SwiftBar is kind enough to tell us about its presence."""
return self.getenv_bool("SWIFTBAR")
[docs]
@staticmethod
def search_venv(folder: Path) -> tuple[str, ...] | None:
"""Search for signs of a virtual env in the provided folder.
Returns CLI arguments that can be used to run `mpm` from the virtualenv
context, or `None` if the folder is not a venv.
Inspired by [autoswitch_virtualenv.plugin.zsh](https://github.com/MichaelAquilina/zsh-autoswitch-virtualenv/blob/master/autoswitch_virtualenv.plugin.zsh#L50)
and [uv's get_interpreter_info.py](https://github.com/astral-sh/uv/blob/f770b25/crates/uv-python/python/get_interpreter_info.py).
"""
if (folder / "Pipfile").is_file():
return (f"PIPENV_PIPFILE='{folder}'", "pipenv", "run", "mpm")
if (folder / "uv.lock").is_file():
# Frozen, and pinned to the folder the lockfile was found in: a bare
# `uv run` would bind to the process cwd instead, and re-lock that
# project (with the user-level uv config folded in) on every launch.
return ("uv", "run", "--frozen", "--project", str(folder), "mpm")
if (folder / "poetry.lock").is_file():
return ("poetry", "run", "--directory", str(folder), "mpm")
if (folder / "requirements.txt").is_file() or (folder / "setup.py").is_file():
return (
f"VIRTUAL_ENV='{folder}'",
"python",
"-m",
"meta_package_manager",
)
return None
[docs]
def search_mpm(self) -> Generator[tuple[str, ...], None, None]:
"""Iterate over possible CLI commands to execute `mpm`.
Should be able to produce the full spectrum of alternative commands we can use
to invoke `mpm` over different context.
The order in which the candidates are returned by this method is conserved by
the `ranked_mpm()` method below.
Venv-based findings come first, because the plugin prefers the `mpm` it
is part of. This file ships inside the package, so walking back up its
own folders reaches the project that installed it, and that `mpm` is the
one this plugin was released with, whose dependencies are already
resolved. Both hosts import the file through a symlink into their own
plugin folder, hence the resolution below: an unresolved path walks that
folder and finds nothing, leaving the plugin to drive whichever other
`mpm` the system answers with.
The rest are fallbacks, for a plugin that reached the host on its own: a
system-wide installation, then the module under an interpreter. None of
them is trusted on sight, `check_mpm()` running each before it is ranked.
"""
# This script might be itself part of an mpm installation that was deployed in
# a virtualenv. So walk back the whole folder tree from here in search of a
# virtualenv. The path is resolved first: both hosts are installed by
# symlinking this file into their own plugin folder, and that folder is
# where an unresolved `__file__` walks, never the installation the script
# belongs to.
for folder in Path(__file__).resolve().parents:
# Stop at Home: neither it nor any folder above it is a project of
# the user's, and scanning on reaches `/` by way of every shared
# parent a stray lockfile could sit in.
if folder == Path.home():
break
venv_cli = self.search_venv(folder)
if not venv_cli:
continue
yield venv_cli
# Search for an mpm executable in the environment, be it a script or a binary.
mpm_bin = which("mpm")
if mpm_bin:
yield (mpm_bin,)
# Try the Python interpreter running this script, then python3 from PATH.
# No version probing needed: check_mpm() validates runnability.
seen = set()
for py_path in (sys.executable, which("python3")):
if not py_path:
continue
# Deduplicated on the path as written, never on its target: a venv
# interpreter is a symlink to the one it was built from, and that
# target sees none of the venv's packages. Two names for a single
# interpreter cost one extra probe here, where a resolved key drops
# a whole environment that holds `mpm`.
normalized = os.path.normcase(py_path)
if normalized in seen:
continue
seen.add(normalized)
yield (py_path, "-m", "meta_package_manager")
[docs]
def check_mpm(
self, mpm_cli_args: tuple[str, ...]
) -> tuple[
bool, bool, tuple[int, ...] | None, str | Exception | None, str | None
]:
"""Test-run mpm execution and extract its version.
Two readings of the same string come back. The numeric tuple is what
compares against {data}`MPM_MIN_VERSION`; the release is the token as
printed, which a development build spells `8.0.0.dev0+40ce0879`. The
release is last because `ranked_mpm` sorts candidates on this tuple:
anything inserted earlier would join the ranking.
"""
error: str | Exception | None = None
try:
process = run(
# Output a color-less version just in case the script is not run in a
# non-interactive shell, or Click/Click-Extra autodetection fails.
(*mpm_cli_args, "--no-color", "--version"),
capture_output=True,
encoding="utf-8",
check=False,
)
error = process.stderr
except FileNotFoundError as ex:
error = ex
runnable = False
version = None
release = None
up_to_date = False
# Is mpm runnable as-is with provided CLI arguments? Check the error
# first: on a FileNotFoundError probe, `process` was never assigned.
if not error and not process.returncode:
runnable = True
# This regular expression is designed to extract the version number,
# whether it is surrounded by ANSI color escape sequence or not.
match = re.compile(
r"""
.+ # Any string
\ # A space
version # The "version" string
\ # A space
[^\.]*? # Any minimal (non-greedy) string without a dot
(?P<release>
(?P<version>[0-9]+(?:\.[0-9]+)+) # Version composed of numbers and dots
[^\s\x1b]* # Any suffix, stopping short of an ANSI escape
)
.*? # Any trailing string (ANSI codes, etc.)
$ # End of the string
""",
re.VERBOSE | re.MULTILINE,
).search(process.stdout)
if match:
version = self.str_to_version(match.groupdict()["version"])
release = match.groupdict()["release"]
# Is mpm too old?
if version >= MPM_MIN_VERSION:
up_to_date = True
return runnable, up_to_date, version, error, release
[docs]
@cached_property
def ranked_mpm(
self,
) -> list[
tuple[
tuple[str, ...],
bool,
bool,
tuple[int, ...] | None,
str | Exception | None,
str | None,
]
]:
"""Rank the mpm candidates we found on the system.
Sort them by:
- runnability
- up-to-date status
- version number
- error
On tie, the order from `search_mpm` is respected.
"""
all_mpm = (
(mpm_candidate, self.check_mpm(mpm_candidate))
for mpm_candidate in self.search_mpm()
)
return [
(mpm_args, *mpm_status)
for mpm_args, mpm_status in sorted(all_mpm, key=itemgetter(1), reverse=True)
]
[docs]
@cached_property
def best_mpm(
self,
) -> tuple[
tuple[str, ...],
bool,
bool,
tuple[int, ...] | None,
str | Exception | None,
str | None,
]:
return self.ranked_mpm[0]
[docs]
@staticmethod
def pp(label: str, *args: str | None) -> None:
"""Print one menu-line with the SwiftBar/Xbar dialect.
First argument is the menu-line label, separated by a pipe to all other non-
empty parameters, themselves separated by a space.
Skip printing of the line if label is empty. A `None` parameter renders
nothing, so a package without an upgrade CLI still gets its label-only
menu line.
"""
if label.strip():
print(
# Do not strip the label to keep character alignments, especially in
# table rendering and Python tracebacks.
label,
"|",
*(arg.strip() for arg in args if arg and arg.strip()),
sep=" ",
)
[docs]
def print_error(self, message: str | Exception, submenu: str = "") -> None:
"""Print a formatted error message line by line.
A red, fixed-width font is used to preserve traceback and exception layout. For
compactness, the block message is dedented and empty lines are skipped.
Message is always casted to a string as we allow passing of exception objects
and have them rendered.
"""
for line in map(methodcaller("rstrip"), dedent(str(message)).splitlines()):
if line:
self.pp(
f"{submenu}{line}",
self.error_font,
"trim=false",
"ansi=false",
"emojize=false",
"symbolize=false" if self.is_swiftbar else "",
)
[docs]
def print_about(self) -> None:
"""Footer naming both halves of the install and the CLI behind them.
This script and `mpm` are installed separately and upgraded
separately: a plugin file copied into the host's folder stays at the
version it was copied at while `mpm` moves under it, and nothing else
in the menu shows that drift. The mpm line reports the release as
printed, suffix included, where the ranking compares numbers alone.
Kept to a single collapsed row so a menu opened for its packages is
not pushed down by three lines of provenance.
"""
host = "SwiftBar" if self.is_swiftbar else "Xbar"
mpm_args, runnable, _up_to_date, _version, _error, release = self.best_mpm
print("---")
self.pp("About", self.default_font)
self.pp(
f"--Meta Package Manager ({host} plugin) {self.plugin_version}",
self.default_font,
)
self.pp(
f"--mpm {release}" if runnable else "--mpm not found",
self.default_font,
)
if runnable:
self.pp(f"--{' '.join(mpm_args)}", self.monospace_font)
self.pp("--Documentation", f"href={PLUGIN_DOCS_URL}", self.default_font)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument(
"--search-mpm",
action="store_true",
help="Locate all mpm on the system and sort them by best candidates.",
)
args = parser.parse_args()
plugin = MPMPlugin()
if args.search_mpm:
for candidate in plugin.ranked_mpm:
mpm_args, runnable, up_to_date, version, error, release = candidate
print(
f"{' '.join(mpm_args)} | runnable: {runnable} | "
f"up to date: {up_to_date} | version: {version} | "
f"release: {release} | error: {error!r}"
)
else:
plugin.print_menu()