#!/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]
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 "",
)
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()