Source code for meta_package_manager.bar_plugin

#!/usr/bin/env python3
# <xbar.title>Meta Package Manager</xbar.title>
# <xbar.version>v5.21.1</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://kdeldycke.github.io/meta-package-manager/bar-plugin.html</xbar.abouturl>
# <xbar.var>boolean(VAR_SUBMENU_LAYOUT=false): Group packages into a sub-menu for each manager.</xbar.var>
# <xbar.var>boolean(VAR_TABLE_RENDERING=true): Aligns package names and versions in a table for easier visual parsing.</xbar.var>
# XXX Deactivate font-related options for Xbar. Default variable value does not allow `=` character in Xbar. See: https://github.com/matryer/xbar/issues/832
# <!--xbar.var>string(VAR_DEFAULT_FONT=""): Font parameters for regular text.</xbar.var-->
# <!--xbar.var>string(VAR_MONOSPACE_FONT="font=Menlo size=12"): Font parameters for monospace text. Used for table rendering and error messages.</xbar.var-->
# <swiftbar.environment>[VAR_SUBMENU_LAYOUT: false, VAR_TABLE_RENDERING: true, VAR_DEFAULT_FONT: , VAR_MONOSPACE_FONT: font=Menlo size=12]</swiftbar.environment>
"""Xbar and SwiftBar plugin for Meta Package Manager (i.e. 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 enum import Enum
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
from typing import Generator

PYTHON_MIN_VERSION = (3, 9, 0)
"""Minimal requirement is aligned to macOS default.

See: https://kdeldycke.github.io/meta-package-manager/bar-plugin.html#python-3-9-required
"""

SWIFTBAR_MIN_VERSION = (2, 1, 2)
"""SwiftBar v2.1.2 fix an issue with multiple parameters in the font strings.

See: 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."""

Venv = Enum("Venv", ["PIPENV", "UV", "POETRY", "VIRTUALENV"])
"""Type of virtualenv we are capable of detecting."""


[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`` to translate strings into boolean. See: https://github.com/python/cpython/blob/89192c4/Lib/configparser.py#L597-L599 """ value = MPMPlugin.getenv_str(var) if value is None: return default return RawConfigParser.BOOLEAN_STATES[value]
[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: - https://github.com/swiftbar/SwiftBar?tab=readme-ov-file#parameters - 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))
@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) @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 ) @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 ) @cached_property def error_font(self) -> str: """Error font is based on monospace font.""" return self.normalize_params(f"{self.monospace_font} color=red size=10") @cached_property def is_swiftbar(self) -> bool: """SwiftBar is kind enough to tell us about its presence.""" return self.getenv_bool("SWIFTBAR") @cached_property def all_pythons(self) -> list[str]: """Search for any Python on the system. Returns a generator of normalized and deduplicated ``Path`` to Python binaries. Filters out old Python interpreters. We first try to locate Python by respecting the environment variables as-is, i.e. as defined by the user. Then we return the Python interpreter used to execute this script. TODO: try to tweak the env vars to look for homebrew location etc? """ collected = [] seen = set() for bin_name in ("python3", "python", sys.executable): py_path = which(bin_name) if not py_path: continue normalized_path = os.path.normcase(Path(py_path).resolve()) if normalized_path in seen: continue seen.add(normalized_path) process = run( ( normalized_path, "-c", "import sys; " "v = sys.version_info; " # Ignore releaselevel and serial component of the version number. "print(f'{v.major}.{v.minor}.{v.micro}')", ), capture_output=True, encoding="utf-8", ) python_version = self.str_to_version(process.stdout) # Is Python too old? if python_version < PYTHON_MIN_VERSION: continue collected.append(normalized_path) return collected
[docs] @staticmethod def search_venv(folder: Path) -> tuple[Venv, tuple[str, ...]] | None: """Search for signs of a virtual env in the provided folder. Returns the type of the detected venv and CLI arguments that can be used to run a command from the virtualenv context. Returns ``(None, 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 Venv.PIPENV, (f"PIPENV_PIPFILE='{folder}'", "pipenv", "run", "mpm") if (folder / "uv.lock").is_file(): return Venv.UV, ("uv", "run", "mpm") if (folder / "poetry.lock").is_file(): return Venv.POETRY, ("poetry", "run", "--directory", str(folder), "mpm") if (folder / "requirements.txt").is_file() or (folder / "setup.py").is_file(): return Venv.VIRTUALENV, ( 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. We prioritize venv-based findings first, as they're more likely to have all dependencies installed and sorted out. They're also our prime candidates in unittests. Then we search for system-wide installation. And finally Python modules. """ # 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. for folder in Path(__file__).parents: # Stop looking beyond Home. if folder == Path.home(): continue venv_found = self.search_venv(folder) if not venv_found: continue yield venv_found[1] # 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,) # Search for a meta_package_manager package installed in any Python found on # the system. for python_path in self.all_pythons: yield python_path, "-m", "meta_package_manager"
[docs] def check_mpm( self, mpm_cli_args: tuple[str, ...] ) -> tuple[bool, bool, tuple[int, ...] | None, str | Exception | None]: """Test-run mpm execution and extract its version.""" 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", ) error = process.stderr except FileNotFoundError as ex: error = ex runnable = False version = None up_to_date = False # Is mpm runnable as-is with provided CLI arguments? if not process.returncode and not error: 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<version>[0-9\.]+) # Version composed of numbers and dots [^\.]*? # Any minimal (non-greedy) string without a dot $ # End of the string """, re.VERBOSE | re.MULTILINE, ).search(process.stdout) if match: version = self.str_to_version(match.groupdict()["version"]) # Is mpm too old? if version >= MPM_MIN_VERSION: up_to_date = True return runnable, up_to_date, version, error
@cached_property def ranked_mpm( self, ) -> list[ tuple[ tuple[str, ...], bool, bool, tuple[int, ...] | None, str | Exception | 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) ] @cached_property def best_mpm( self, ) -> tuple[ tuple[str, ...], bool, bool, tuple[int, ...] | None, str | Exception | None ]: return self.ranked_mpm[0]
[docs] @staticmethod def pp(label: str, *args: str) -> None: """Print one menu-line with the Xbar/SwiftBar 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. """ if label.strip(): print( # Do not strip the label to keep character alignements, especially in # table rendering and Python tracebacks. label, "|", *(line for line in map(methodcaller("strip"), args) if line), 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_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 = 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 >= v{min_version_str}", f"shell={self.all_pythons[0]}", "param1=-m", "param2=pip", "param3=install", "param4=--upgrade", # XXX This seems broken beyond repair. No amount of workaround works. # See: # https://github.com/matryer/xbar/issues/831 # https://github.com/swiftbar/SwiftBar/issues/308 # Fallback to the only version that is working on SwiftBar. f'param5=\\"meta-package-manager>={min_version_str}\\"', self.error_font, "refresh=true", "terminal=true", ) return # Force a sync of all local package databases. run((*mpm_args, "--verbosity", "ERROR", "sync")) # 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", "outdated", "--plugin-output"), capture_output=True, encoding="utf-8", ) # Bail-out immediately on errors related to mpm self-execution or if mpm is # not able to produce any output. if process.stderr or not process.stdout: self.print_error_header() self.print_error(process.stderr) return # Capturing the output of mpm and re-printing it will introduce an extra # line returns, hence the extra rstrip() call. print(process.stdout.rstrip())
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 mpm_args, runnable, up_to_date, version, error in plugin.ranked_mpm: print( f"{' '.join(mpm_args)} | runnable: {runnable} | " f"up to date: {up_to_date} | version: {version} | error: {error!r}" ) else: plugin.print_menu()