Source code for meta_package_manager.bar_plugin

#!/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] @staticmethod def print_error_header() -> None: """Generic header for blocking error.""" MPMPlugin.pp("❗️", "dropdown=false") print("---")
[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)
[docs] def print_menu(self) -> None: """Print the main menu.""" # Check if we have a recent version of SwiftBar. # XXX Xbar does not provide yet a version number in the environment variables. if self.is_swiftbar: swiftbar_version_str = self.getenv_str("SWIFTBAR_VERSION", "") swiftbar_version = self.str_to_version(swiftbar_version_str) if not swiftbar_version or swiftbar_version < SWIFTBAR_MIN_VERSION: self.print_error_header() self.print_error( f"SwiftBar v{swiftbar_version_str} found, but " f"v{self.version_to_str(SWIFTBAR_MIN_VERSION)} is required.", ) return # Check if we have a recent version of mpm. mpm_args, runnable, up_to_date, _version, error, _release = self.best_mpm if not runnable or not up_to_date: self.print_error_header() if error: self.print_error(error) print("---") action_msg = "Install" if not runnable else "Upgrade" min_version_str = self.version_to_str(MPM_MIN_VERSION) self.pp( f"{action_msg} mpm >= {min_version_str} with uv", f"shell={INSTALL_ARGV[0]}", *( f"param{index}={arg}" for index, arg in enumerate(INSTALL_ARGV[1:], start=1) ), self.error_font, "refresh=true", "terminal=true", ) self.pp( "Open mpm installation instructions", f"href={INSTALL_DOCS_URL}", self.error_font, ) self.print_about() return # Force a sync of all local package databases. run( (*mpm_args, "--verbosity", "ERROR", "--timeout", str(MPM_TIMEOUT), "sync"), check=False, ) # Fetch outdated packages from all package managers available on the system. # We defer all rendering to mpm itself so it can compute more intricate layouts. process = run( # We silence all errors but the CRITICAL ones. All others will be captured # by mpm in --plugin-output mode and rendered back into each manager # section. ( *mpm_args, "--verbosity", "CRITICAL", "--timeout", str(MPM_TIMEOUT), "outdated", "--plugin-output", ), capture_output=True, encoding="utf-8", check=False, ) # Bail-out immediately on errors related to mpm self-execution or if mpm is # not able to produce any output. An empty output is deliberate when the # user asked for the icon to vanish while everything is up to date, and is # then forwarded as-is for the host to hide the plugin. if process.stderr or (not process.stdout and not self.hide_when_up_to_date): self.print_error_header() self.print_error(process.stderr) self.print_about() return # Capturing the output of mpm and re-printing it will introduce an extra # line returns, hence the extra rstrip() call. if process.stdout: print(process.stdout.rstrip()) self.print_about()
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()