# Copyright Kevin Deldycke <[email protected]> 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.
"""Homebrew-specific tests."""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
from unittest.mock import call, patch
import pytest
from meta_package_manager.execution import CLIError
from meta_package_manager.managers.homebrew import Brew, Cask
from meta_package_manager.package import (
EMPTY_METADATA,
Checksum,
ChecksumAlgorithm,
Dependency,
DependencyScope,
PackageMetadata,
Supplier,
)
[docs]
@pytest.mark.parametrize(
"package_id",
(
"wget",
"ffmpeg",
"[email protected]",
"firefox",
),
)
@pytest.mark.parametrize("manager_class", (Brew, Cask))
def test_trust_tap_skips_core_packages(manager_class, package_id):
"""Core formulae and casks live on trusted taps and must not trigger trust."""
manager = manager_class()
with patch.object(manager, "run_cli") as run_cli:
manager.trust_tap(package_id)
run_cli.assert_not_called()
[docs]
@pytest.mark.parametrize(
"manager_class,package_id,tap_id",
(
(Brew, "gromgit/fuse/ntfs-3g-mac", "gromgit/fuse"),
(Brew, "smudge/smudge/nightlight", "smudge/smudge"),
(
Cask,
"homebrew/cask-versions/firefox-developer-edition",
"homebrew/cask-versions",
),
),
)
def test_trust_tap_qualified_package(manager_class, package_id, tap_id):
"""Tap-qualified IDs are tapped (idempotent) and trusted before install."""
manager = manager_class()
with patch.object(manager, "run_cli") as run_cli:
manager.trust_tap(package_id)
assert run_cli.call_args_list == [
call("tap", tap_id, auto_post_args=False, auto_extra_args=False),
call("trust", package_id, auto_extra_args=False),
]
[docs]
@pytest.mark.parametrize("manager_class", (Brew, Cask))
def test_no_greedy_by_default(manager_class):
"""mpm passes no `--greedy` of its own: the user adds it per operation."""
manager = manager_class()
assert "--greedy" not in manager.upgrade_all_cli()
[docs]
def test_extra_args_reach_their_own_command(monkeypatch):
"""`extra_args` set per operation reach the commands of that operation alone.
A `--greedy` set through `post_args` reached `brew list`, which rejects it
([#2171](https://github.com/kdeldycke/meta-package-manager/issues/2171)). Set
on `outdated` and `upgrade_all`, it joins `brew outdated` and `brew upgrade`,
before the `--cask` selector, and leaves `brew list` alone. The `brew tap`
and `brew trust` helpers of an install get none of the install's arguments.
"""
manager = Cask()
manager.operation_extra_args = {
"install": ("--no-binaries",),
"outdated": ("--greedy",),
"upgrade_all": ("--greedy",),
}
commands: list[tuple[str, ...]] = []
def record(*args, **kwargs):
commands.append(tuple(map(str, args)))
return ""
monkeypatch.setattr(manager, "run", record)
list(manager.installed)
list(manager.outdated)
manager.install("homebrew/cask-versions/firefox-developer-edition")
upgrade_all = manager.upgrade_all_cli()
listing, outdated, tap, trust, install = commands
assert listing[-4:] == ("list", "--quiet", "--versions", "--cask")
assert outdated[-4:] == ("outdated", "--json=v2", "--greedy", "--cask")
assert tap[-2:] == ("tap", "homebrew/cask-versions")
assert trust[-3:] == (
"trust",
"homebrew/cask-versions/firefox-developer-edition",
"--cask",
)
assert install[-3:] == (
"homebrew/cask-versions/firefox-developer-edition",
"--no-binaries",
"--cask",
)
assert upgrade_all[-5:] == ("upgrade", "--quiet", "--yes", "--greedy", "--cask")
[docs]
@pytest.mark.parametrize("manager_class", (Brew, Cask))
def test_install_routes_through_trust_tap(manager_class):
"""install() always calls trust_tap() so the gate is uniform across paths."""
manager = manager_class()
with (
patch.object(manager, "trust_tap") as trust_tap,
patch.object(manager, "run_cli") as run_cli,
):
manager.install("gromgit/fuse/ntfs-3g-mac")
trust_tap.assert_called_once_with("gromgit/fuse/ntfs-3g-mac")
run_cli.assert_called_once_with(
"install",
"--quiet",
"gromgit/fuse/ntfs-3g-mac",
)
def _write_formula_sbom(tmp_path, formula_name, external_refs, dep_refs=()):
"""Write a minimal `sbom.spdx.json` shaped like Homebrew's own.
Only the fields {meth}`Homebrew._upstream_purls` reads are populated:
the source package's `externalRefs`, plus an optional dependency
package carrying refs of its own.
"""
packages = [
{
"SPDXID": f"SPDXRef-Archive-{formula_name}-src",
"name": formula_name,
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceLocator": locator,
"referenceType": "purl",
}
for locator in external_refs
],
},
]
if dep_refs:
packages.append({
"SPDXID": "SPDXRef-Package-openssl",
"name": "openssl",
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceLocator": locator,
"referenceType": "purl",
}
for locator in dep_refs
],
})
sbom_file = tmp_path / "sbom.spdx.json"
sbom_file.write_text(json.dumps({"packages": packages}), encoding="UTF-8")
return sbom_file
[docs]
@pytest.mark.parametrize(
("external_refs", "expected"),
(
pytest.param(
("pkg:brew/[email protected]", "pkg:pypi/[email protected]"),
("pkg:pypi/[email protected]",),
id="registry-purl-beside-the-brew-one",
),
pytest.param(
("pkg:brew/[email protected]",),
(),
id="no-registry-source",
),
pytest.param(
(
"pkg:brew/[email protected]",
"pkg:npm/%40earendil-works/[email protected]",
),
("pkg:npm/%40earendil-works/[email protected]",),
id="percent-encoded-npm-namespace",
),
pytest.param(
("pkg:brew/homebrew%2Fcore/[email protected]",),
(),
id="tap-qualified-brew-purl-is-still-a-brew-purl",
),
pytest.param(
("pkg:brew/[email protected]", "not-a-purl", "pkg:pypi/[email protected]"),
("pkg:pypi/[email protected]",),
id="unparseable-locator-skipped",
),
),
)
def test_upstream_purls_reads_the_source_package(tmp_path, external_refs, expected):
"""Only non-brew purls on the source package are lifted."""
sbom_file = _write_formula_sbom(tmp_path, "pkg", external_refs)
assert Brew._upstream_purls(sbom_file, "pkg") == expected
[docs]
def test_upstream_purls_ignores_dependency_packages(tmp_path):
"""A dependency's own coordinate must not be attributed to the formula."""
sbom_file = _write_formula_sbom(
tmp_path,
"curl",
("pkg:brew/[email protected]",),
dep_refs=("pkg:brew/[email protected]", "pkg:generic/[email protected]"),
)
assert Brew._upstream_purls(sbom_file, "curl") == ()
[docs]
@pytest.mark.parametrize(
("sbom_path", "formula_name"),
(
pytest.param(None, "curl", id="no-sbom-file"),
pytest.param(Path("/nonexistent/sbom.spdx.json"), "curl", id="unreadable"),
pytest.param(Path("/nonexistent/sbom.spdx.json"), None, id="no-formula-name"),
),
)
def test_upstream_purls_degrades_to_empty(sbom_path, formula_name):
"""A missing or unreadable document costs coverage, never an error."""
assert Brew._upstream_purls(sbom_path, formula_name) == ()
[docs]
@pytest.mark.parametrize(
("stderr", "hint"),
(
pytest.param(
"Error: Cask 'macwhisper' is not installed.",
"The Caskroom still holds a directory for macwhisper, which is not "
"installed. Homebrew lists no cask while that directory remains. "
"Remove it with `brew uninstall --cask --force macwhisper`.",
id="stale-caskroom-directory",
),
pytest.param(
"Error: Cask 'macwhisper' is unavailable: No Cask with this name exists.",
None,
id="other-cask-error",
),
),
)
def test_installed_names_a_stale_caskroom_directory(monkeypatch, caplog, stderr, hint):
"""A cask listing that brew aborted relays the error, plus the fix when known."""
caplog.set_level(logging.WARNING)
cask = Cask()
monkeypatch.setattr(cask, "cli_errors", [])
monkeypatch.setattr(cask, "cli_path", Path("/opt/homebrew/bin/brew"), raising=False)
monkeypatch.setattr(cask, "_spawn", lambda *args, **kwargs: (1, "", stderr))
assert list(cask.installed) == []
expected = [stderr] if hint is None else [stderr, hint]
assert [record.getMessage() for record in caplog.records] == expected
# Records of `brew info --json=v2`, as Homebrew `7.0.10` printed them on macOS arm64,
# trimmed to the keys the metadata parsers read. Every kept value is verbatim. The
# `ncar-ncl` record comes from `brew info --json=v2 --cask ncar-ncl`: no installed
# cask of that host depended on another cask or on a formula.
CURL_BOTTLE_SHA256 = "16b951fa8ecede2c254441c26e34814fed37c1e97382dad3016012aa2cbf20ac"
CURL_FORMULA: dict[str, Any] = {
"name": "curl",
"full_name": "curl",
"tap": "homebrew/core",
"oldnames": ["wcurl"],
"aliases": [],
"desc": "Get a file from an HTTP, HTTPS or FTP server",
"license": "curl",
"homepage": "https://curl.se",
"versions": {"stable": "8.23.0"},
"urls": {"stable": {"url": "https://curl.se/download/curl-8.23.0.tar.bz2"}},
"keg_only": True,
"keg_only_reason": {"reason": ":provided_by_macos", "explanation": ""},
"dependencies": [
"brotli",
"libnghttp2",
"libnghttp3",
"libngtcp2",
"libpsl",
"libssh2",
"openssl@4",
"zstd",
],
"build_dependencies": ["pkgconf"],
"test_dependencies": ["pkgconf"],
"optional_dependencies": [],
"recommended_dependencies": [],
"bottle": {
"stable": {"files": {"arm64_golden_gate": {"sha256": CURL_BOTTLE_SHA256}}}
},
"installed": [
{"version": "8.23.0", "installed_on_request": True, "poured_from_bottle": True},
],
}
GIT_DELTA_FORMULA: dict[str, Any] = {
"name": "git-delta",
"full_name": "git-delta",
"aliases": ["delta"],
"oldnames": [],
"desc": "Syntax-highlighting pager for git and diff output",
}
NIGHTLIGHT_FORMULA: dict[str, Any] = {
"name": "nightlight",
"full_name": "smudge/smudge/nightlight",
"aliases": [],
"oldnames": [],
"desc": "A CLI for configuring 'Night Shift' on macOS 🌕🌖🌗🌘🌑",
}
TY_FORMULA: dict[str, Any] = {
"name": "ty",
"full_name": "ty",
"tap": "homebrew/core",
"desc": "Extremely fast Python type checker, written in Rust",
"license": "MIT",
"homepage": "https://docs.astral.sh/ty/",
"versions": {"stable": "0.0.91"},
"build_dependencies": ["rust"],
"installed": [
{"version": "0.0.90", "installed_on_request": True, "poured_from_bottle": True},
],
}
ADGUARD_CASK: dict[str, Any] = {
"token": "adguard",
"full_token": "adguard",
"tap": "homebrew/cask",
"name": ["AdGuard"],
"desc": "Stand alone ad blocker",
"homepage": "https://adguard.com/",
"url": "https://static.adguard.com/mac/release/AdGuard-2.20.2.2629.dmg",
"sha256": "735aab5f60baa115fc238b2b91d1f8571b00309a8ed71797ff38ed114faba7b0",
"auto_updates": True,
"depends_on": {"macos": {">=": ["12"]}},
}
DISK_DRILL_CASK: dict[str, Any] = {
"token": "disk-drill",
"full_token": "disk-drill",
"tap": "homebrew/cask",
"name": ["Disk Drill"],
"desc": "Data recovery software",
"homepage": "https://www.cleverfiles.com/",
"url": "https://dl.cleverfiles.com/diskdrill.dmg",
"sha256": "no_check",
"auto_updates": True,
"depends_on": {"macos": {}},
}
NCAR_NCL_CASK: dict[str, Any] = {
"token": "ncar-ncl",
"full_token": "ncar-ncl",
"tap": "homebrew/cask",
"name": ["NCAR Command Language", "ncl"],
"desc": "Interpreted language for scientific data analysis and visualization",
"homepage": "https://www.ncl.ucar.edu/",
"url": (
"https://www.earthsystemgrid.org/api/v1/dataset/ncl.662.dap/file/"
"ncl_ncarg-6.6.2-MacOS_10.14_64bit_gnu730.tar.gz"
),
"sha256": "e2cd644f6b1bb41f55480b8818319e60c450998e31e5e489c69a5e84f3d1f359",
"auto_updates": None,
"depends_on": {"cask": ["xquartz"], "formula": ["gcc"], "macos": {}},
}
CASK_SUPPLIER = Supplier(
name="Homebrew Cask", url="https://github.com/Homebrew/homebrew-cask"
)
def _stub_brew(monkeypatch, manager, info, prefix):
"""Answer the two `brew` calls of the metadata batch without spawning `brew`.
`brew --prefix` prints `prefix`, or nothing when it is `None`. `brew info
--json=v2 --installed` prints `info` encoded as JSON, or verbatim when it is
text already, and raises it when it is an exception. Linux and Windows runners
have no `brew`: the stub is what lets these tests run there.
"""
def run_cli(*args, **kwargs):
if args == ("--prefix",):
return f"{prefix or ''}\n"
assert args == ("info", "--json=v2", "--installed")
if isinstance(info, Exception):
raise info
return info if isinstance(info, str) else json.dumps(info)
monkeypatch.setattr(manager, "run_cli", run_cli)
[docs]
@pytest.mark.parametrize(
("output", "prefix"),
(
pytest.param("/opt/homebrew\n", Path("/opt/homebrew"), id="apple-silicon"),
pytest.param(
"/home/linuxbrew/.linuxbrew\n",
Path("/home/linuxbrew/.linuxbrew"),
id="linux",
),
pytest.param("\n", None, id="no-output"),
pytest.param(CLIError(1, "", ""), None, id="failed-call"),
),
)
def test_brew_prefix(monkeypatch, output, prefix):
"""`brew --prefix` locates the Cellar, and a failed call leaves it unknown.
The `--formula` and `--cask` selectors stay off the command: they would ask
for the prefix of a package instead of the prefix of Homebrew.
"""
manager = Brew()
def run_cli(*args, **kwargs):
assert args == ("--prefix",)
assert kwargs == {
"auto_post_args": False,
"auto_extra_args": False,
"must_succeed": True,
}
if isinstance(output, Exception):
raise output
return output
monkeypatch.setattr(manager, "run_cli", run_cli)
assert manager._brew_prefix == prefix