# Copyright Kevin Deldycke <kevin@deldycke.com> 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 json
import logging
import re
from operator import methodcaller
from typing import Iterator
from extra_platforms import LINUX_LIKE, MACOS
from meta_package_manager.base import Package, PackageManager
from meta_package_manager.capabilities import version_not_implemented
from meta_package_manager.version import parse_version
[docs]
class Homebrew(PackageManager):
"""Virtual package manager shared by brew and cask CLI defined below.
Homebrew is the umbrella project providing both brew and brew cask commands.
"""
platforms = {LINUX_LIKE, MACOS}
"""Homebrew core is now compatible with `Linux and Windows Subsystem for Linux (WSL)
2 <https://docs.brew.sh/Homebrew-on-Linux>`_."""
requirement = "2.7.0"
"""Vanilla ``brew`` and ``cask`` CLIs now shares the same version.
`2.7.0 <https://github.com/Homebrew/brew/releases/tag/2.7.0>`_ is the first release
to enforce the use of ``--cask`` option.
"""
# Declare this manager as virtual, i.e. not tied to a real CLI.
virtual = True
extra_env = {
# Disable analytics.
"HOMEBREW_NO_ANALYTICS": "1",
# Disable configuration hints to reduce verbosity.
"HOMEBREW_NO_ENV_HINTS": "1",
# Do not let brew mix the update operation with others. Mpm has a separate
# "sync" command for that. This silo-ed behavior has been requested by user
# since the beginning of mpm:
# https://github.com/kdeldycke/meta-package-manager/issues/36
"HOMEBREW_NO_AUTO_UPDATE": "1",
# See: https://docs.brew.sh/FAQ#why-cant-i-open-a-mac-app-from-an-unidentified-developer
# "HOMEBREW_CASK_OPTS": "--no-quarantine",
}
version_regex = r"Homebrew\s+(?P<version>\S+)"
"""
.. code-block:: shell-session
► brew --version
Homebrew 1.8.6-124-g6cd4c31
Homebrew/homebrew-core (git revision 533d; last commit 2018-12-28)
Homebrew/homebrew-cask (git revision 5095b; last commit 2018-12-28)
"""
@property
def installed(self) -> Iterator[Package]:
"""Fetch installed packages.
.. code-block:: shell-session
► brew list --versions --formula
ack 2.14
apg 2.2.3
audacity (!) 2.1.2
apple-gcc42 4.2.1-5666.3
atk 2.22.0
bash 4.4.5
bash-completion 1.3_1
boost 1.63.0
c-ares 1.12.0
graphviz 2.40.1 2.40.20161221.0239
quicklook-json latest
.. code-block:: shell-session
► brew list --versions --cask
aerial 1.2beta5
android-file-transfer latest
audacity (!) 2.1.2
firefox 49.0.1
flux 37.7
gimp 2.8.18-x86_64
java 1.8.0_112-b16
tunnelblick 3.6.8_build_4625 3.6.9_build_4685
virtualbox 5.1.8-111374 5.1.10-112026
.. todo::
Use the ``removed`` variable to detect removed packages (which are
reported with a ``(!)`` flag). See:
https://github.com/caskroom/homebrew-cask/blob/master/doc
/reporting_bugs/uninstall_wrongly_reports_cask_as_not_installed.md
and https://github.com/kdeldycke/meta-package-manager/issues/17 .
"""
output = self.run_cli("list", "--versions")
regexp = re.compile(
r"""
(?P<package_id>\S+) # Any non-empty characters.
(?P<removed> \(!\))? # Package removed flag.
\ # A space.
(?P<versions>.+) # Versions.
""",
re.VERBOSE,
)
for package_id, _removed, versions in map(
methodcaller("groups"),
regexp.finditer(output),
):
# Keep highest version found.
version = max(map(parse_version, versions.split()))
yield self.package(id=package_id, installed_version=version)
@property
def outdated(self) -> Iterator[Package]:
"""Fetch outdated packages.
.. code-block:: shell-session
► brew outdated --json=v2 --formula | jq
{
"formulae": [
{
"name": "pygobject3",
"installed_versions": [
"3.36.1"
],
"current_version": "3.38.0",
"pinned": false,
"pinned_version": null
},
{
"name": "rav1e",
"installed_versions": [
"0.3.3"
],
"current_version": "0.3.4",
"pinned": false,
"pinned_version": null
}
],
"casks": []
}
.. code-block:: shell-session
► brew outdated --json=v2 --cask | jq
{
"formulae": [],
"casks": [
{
"name": "electrum",
"installed_versions": "4.0.2",
"current_version": "4.0.3"
},
{
"name": "qlcolorcode",
"installed_versions": "3.0.2",
"current_version": "3.1.1"
}
]
}
.. code-block:: shell-session
► brew outdated --json=v2 --greedy --cask | jq
{
"formulae": [],
"casks": [
{
"name": "amethyst",
"installed_versions": "0.14.3",
"current_version": "0.15.3"
},
{
"name": "balenaetcher",
"installed_versions": "1.5.106",
"current_version": "1.5.108"
},
{
"name": "caldigit-thunderbolt-charging",
"installed_versions": "latest",
"current_version": "latest"
},
{
"name": "electrum",
"installed_versions": "4.0.2",
"current_version": "4.0.3"
},
{
"name": "lg-onscreen-control",
"installed_versions": "5.33,cV8xqv5TSZA.upgrading, 5.47,yi5XuIZw6hg",
"current_version": "5.48,uYXSwyUCNFBbSch9PFw"
}
]
}
"""
# Build up the list of CLI options.
options = ["--json=v2"]
# Includes auto-update packages or not.
if not self.ignore_auto_updates:
options.append("--greedy")
# List available updates.
output = self.run_cli("outdated", options)
if output:
package_list = json.loads(output)
for pkg_info in package_list["formulae"] + package_list["casks"]:
# Interpret installed versions.
versions = pkg_info["installed_versions"]
if isinstance(versions, str):
versions = versions.split(", ")
installed_version = max(map(parse_version, versions))
latest_version = parse_version(pkg_info["current_version"])
# Skip packages not offering upgradeable version.
package_id = pkg_info["name"]
if installed_version == latest_version:
logging.debug(
f"Ignore {package_id} upgrade "
f"from {installed_version} to {latest_version}.",
)
continue
yield self.package(
id=package_id,
installed_version=installed_version,
latest_version=latest_version,
)
[docs]
def search(self, query: str, extended: bool, exact: bool) -> Iterator[Package]:
"""Fetch matching packages.
.. caution::
Search does not supports extended mode.
.. code-block:: shell-session
► brew search sed
==> Formulae
gnu-sed ✔ libxdg-basedir minised
==> Casks
eclipse-dsl marsedit
focused physicseditor
google-adwords-editor prefs-editor
licensed subclassed-mnemosyne
.. code-block:: shell-session
► brew search sed --formulae
==> Formulae
gnu-sed ✔ libxdg-basedir minised
.. code-block:: shell-session
► brew search sed --cask
==> Casks
eclipse-dsl marsedit
focused physicseditor
google-adwords-editor prefs-editor
licensed subclassed-mnemosyne
.. code-block:: shell-session
► brew search python --formulae
==> Formulae
app-engine-python boost-python3 python ✔ python-yq
boost-python gst-python python-markdown python@3.8 ✔
.. code-block:: shell-session
► brew search "/^ssed$/" --formulae
==> Formulae
ssed
.. code-block:: shell-session
► brew search "/^sed$/" --formulae
Error: No formula or cask found for "/^sed$/".
.. code-block:: shell-session
► brew search tetris --formulae --desc
==> Formulae
bastet: Bastard Tetris
netris: Networked variant of tetris
vitetris: Terminal-based Tetris clone
yetris: Customizable Tetris for the terminal
.. code-block:: shell-session
► brew search tetris --cask --desc
==> Casks
not-tetris: (Not Tetris) [no description]
tetrio: (TETR.IO) Free-to-play Tetris clone
More doc at: https://docs.brew.sh/Manpage#search--s-options-textregex-
"""
# Keep track of package IDs already matched by the first extended search pass.
matched_ids = set()
# Additional search on description only.
if extended:
output = self.run_cli("search", query, "--desc")
regexp = re.compile(
r"""
(?:==>\s\S+\s)? # Ignore section starting with '==>'.
(?P<package_id>\S+) # Any non-empty characters.
: # Semi-colon.
( # Optional group start (ignored below with _).
\s+ # Blank characters.
\( # Opening parenthesis.
(?P<package_name>.+) # Any string.
\) # Closing parenthesis.
)? # Optional group end.
\s+ # Blank characters.
(?P<description>.+) # Any string.
""",
re.VERBOSE,
)
for package_id, _, package_name, description in regexp.findall(output):
matched_ids.add(package_id)
pkg = self.package(id=package_id, name=package_name)
if description != "[no description]":
pkg.description = description
yield pkg
# Use regexp if exact match is requested.
if exact:
query = f"/^{query}$/"
output = self.run_cli("search", query)
regexp = re.compile(
r"""
(?:==>\s\S+\s)? # Ignore section starting with '==>'.
(?P<package_id>[^\s✔]+) # Anything not a whitespace or ✔.
""",
re.VERBOSE,
)
for package_id in regexp.findall(output):
# Deduplicate search results.
if package_id not in matched_ids:
yield self.package(id=package_id)
@version_not_implemented
def install(self, package_id: str, version: str | None = None) -> str:
"""Install one package.
.. code-block:: shell-session
► brew install jpeginfo --formula
==> Downloading https://ghcr.io/core/jpeginfo/manifests/1.6.1_1-1
############################################################## 100.0%
==> Downloading https://ghcr.io/core/jpeginfo/blobs/sha256:27bb35884368b83
==> Downloading from https://pkg.githubcontent.com/ghcr1/blobs/sha256:27bb3
############################################################## 100.0%
==> Pouring jpeginfo--1.6.1_1.big_sure.bottle.1.tar.gz
🍺 /usr/local/Cellar/jpeginfo/1.6.1_1: 7 files, 77.6KB
.. code-block:: shell-session
► brew install pngyu --cask
==> Downloading https://nukesaq.github.io/Pngyu/download/Pngyu_mac_101.zip
################################################################## 100.0%
==> Installing Cask pngyu
==> Moving App 'Pngyu.app' to '/Applications/Pngyu.app'
🍺 pngyu was successfully installed!
"""
return self.run_cli("install", package_id)
[docs]
def upgrade_all_cli(self) -> tuple[str, ...]:
"""Generates the CLI to upgrade all packages (default) or only the one provided
as parameter.
``brew`` and ``cask`` share the same command.
.. code-block:: shell-session
► brew upgrade --formula
==> Upgrading 2 outdated packages:
node 13.11.0 -> 13.12.0
sdl2 2.0.12 -> 2.0.12_1
==> Upgrading node 13.11.0 -> 13.12.0
==> Downloading https://homebrew.bintray.com/bottles/node-13.tar.gz
==> Downloading from https://akamai.bintray.com/fc/fc0bfb42fe23e960
############################################################ 100.0%
==> Pouring node-13.12.0.catalina.bottle.tar.gz
==> Caveats
Bash completion has been installed to:
/usr/local/etc/bash_completion.d
==> Summary
🍺 /usr/local/Cellar/node/13.12.0: 4,660 files, 60.3MB
Removing: /usr/local/Cellar/node/13.11.0... (4,686 files, 60.4MB)
==> Upgrading sdl2 2.0.12 -> 2.0.12_1
==> Downloading https://homebrew.bintray.com/bottles/sdl2-2.tar.gz
==> Downloading from https://akamai.bintray.com/4d/4dcd635465d16372
############################################################ 100.0%
==> Pouring sdl2-2.0.12_1.catalina.bottle.tar.gz
🍺 /usr/local/Cellar/sdl2/2.0.12_1: 89 files, 4.7MB
Removing: /usr/local/Cellar/sdl2/2.0.12... (89 files, 4.7MB)
==> Checking for dependents of upgraded formulae...
==> No dependents found!
==> Caveats
==> node
Bash completion has been installed to:
/usr/local/etc/bash_completion.d
.. code-block:: shell-session
► brew upgrade --cask
==> Casks with `auto_updates` or `version :latest` will not be upgraded
==> Upgrading 1 outdated packages:
aerial 2.0.7 -> 2.0.8
==> Upgrading aerial
==> Downloading https://github.com/Aerial/download/v2.0.8/Aerial.saver.zip
==> Downloading from https://65be.s3.amazonaws.com/44998092/29eb1e0
==> Verifying SHA-256 checksum for Cask 'aerial'.
==> Backing Screen Saver up to '/usr/local/Caskroom/Aerial.saver'.
==> Removing Screen Saver '/Users/kde/Library/Screen Savers/Aerial.saver'.
==> Moving Screen Saver to '/Users/kde/Library/Screen Savers/Aerial.saver'.
==> Purging files for version 2.0.7 of Cask aerial
🍺 aerial was successfully upgraded!
"""
return self.build_cli("upgrade")
@version_not_implemented
def upgrade_one_cli(
self,
package_id: str,
version: str | None = None,
) -> tuple[str, ...]:
"""Generates the CLI to upgrade all packages (default) or only the one provided
as parameter.
``brew`` and ``cask`` share the same command.
.. code-block:: shell-session
► brew upgrade dupeguru --cask
==> Upgrading 1 outdated package:
dupeguru 4.2.0 -> 4.2.1
==> Upgrading dupeguru
==> Downloading https://github.com/(...)/4.2.1/dupeguru_macOS_Qt_4.2.1.zip
==> Downloading from https://githubusercontent.com/production-release-asset
##################################################################### 100.0%
==> Backing App 'dupeguru.app' up to '/opt/homebrew/.../4.2.0/dupeguru.app'
==> Removing App '/Applications/dupeguru.app'
==> Moving App 'dupeguru.app' to '/Applications/dupeguru.app'
==> Purging files for version 4.2.0 of Cask dupeguru
🍺 dupeguru was successfully upgraded!
"""
return self.build_cli("upgrade", package_id)
[docs]
def remove(self, package_id: str) -> str:
"""Removes a package.
.. code-block:: shell-session
► brew uninstall bat
Uninstalling /usr/local/Cellar/bat/0.21.0... (14 files, 5MB)
"""
return self.run_cli("uninstall", package_id)
[docs]
def sync(self) -> None:
"""Sync package metadata.
.. code-block:: shell-session
► brew update --quiet
Already up-to-date.
"""
self.run_cli("update", "--quiet", auto_post_args=False)
[docs]
def cleanup(self) -> None:
"""Removes things we don't need anymore.
Scrub the cache, including latest version's downloads. Also remove unused
dependencies.
Downloads for all installed formulae and casks will not be deleted.
.. code-block:: shell-session
► brew cleanup -s --prune=all
Removing: ~/Library/Caches/Homebrew/node--1.bottle.tar.gz... (9MB)
Warning: Skipping sdl2: most recent version 2.0.12_1 not installed
Removing: ~/Library/Caches/Homebrew/Cask/aerial--1.8.1.zip... (5MB)
Removing: ~/Library/Caches/Homebrew/Cask/prey--1.9.pkg... (19.9MB)
Removing: ~/Library/Logs/Homebrew/readline... (64B)
Removing: ~/Library/Logs/Homebrew/libfido2... (64B)
Removing: ~/Library/Logs/Homebrew/libcbor... (64B)
More doc at: https://docs.brew.sh/Manpage#cleanup-options-formulacask
.. code-block:: shell-session
► brew autoremove
==> Uninstalling 17 unneeded formulae:
gtkmm3
highlight
lua@5.1
nasm
nghttp2
texi2html
Uninstalling /usr/local/Cellar/nghttp2/1.41.0_1... (26 files, 2.7MB)
Uninstalling /usr/local/Cellar/highlight/3.59... (558 files, 3.5MB)
Warning: The following highlight configuration files have not been removed!
If desired, remove them manually with `rm -rf`:
/usr/local/etc/highlight
/usr/local/etc/highlight/filetypes.conf
/usr/local/etc/highlight/filetypes.conf.default
Uninstalling /usr/local/Cellar/gtkmm3/3.24.2_1... (1,903 files, 173.7MB)
Uninstalling /usr/local/Cellar/texi2html/5.0... (279 files, 6.2MB)
Uninstalling /usr/local/Cellar/lua@5.1/5.1.5_8... (22 files, 245.6KB)
Uninstalling /usr/local/Cellar/nasm/2.15.05... (29 files, 2.9MB)
"""
self.run_cli("autoremove", auto_post_args=False)
self.run_cli("cleanup", "-s", "--prune=all", auto_post_args=False)
[docs]
class Brew(Homebrew):
name = "Homebrew Formulae"
homepage_url = "https://brew.sh"
cli_names = ("brew",)
post_args = ("--formula",)
[docs]
class Cask(Homebrew):
name = "Homebrew Cask"
homepage_url = "https://github.com/Homebrew/homebrew-cask"
platforms = {MACOS}
"""Casks are only available on macOS, not Linux or WSL."""
cli_names = ("brew",)
post_args = ("--cask",)