# 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.
"""Render the installed-package inventory as a Brewfile.
Defines :py:func:`build_brewfile` and the helpers used by ``mpm dump --brewfile``
to emit a Brewfile that ``brew bundle install`` can consume.
.. note::
Brewfile is a Ruby DSL. The format reference is the Homebrew Bundle source at
``Library/Homebrew/bundle/dsl.rb`` and the extensions under
``Library/Homebrew/bundle/extensions/`` (``brew`` `6.0.0+
<https://brew.sh/2026/06/11/homebrew-6.0.0/>`_).
"""
from __future__ import annotations
import json
import logging
from collections import Counter
from datetime import datetime, timezone
from . import __version__
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping
from .manager import PackageManager
from .package import Package
BUNDLE_ENTRY_TYPES: tuple[str, ...] = (
"tap",
"brew",
"cask",
"mas",
"vscode",
"npm",
"cargo",
"uv",
"winget",
"flatpak",
)
"""Canonical emission order of Brewfile sections.
Mirrors the registration order of ``Homebrew::Bundle.dump_package_types`` and the
extensions under ``Library/Homebrew/bundle/extensions/``. ``tap`` always comes first
so that any third-party tap a downstream ``brew`` or ``cask`` entry references is
registered before the install step runs.
"""
DEFAULT_TAPS: frozenset[tuple[str, str]] = frozenset({
("homebrew", "core"),
("homebrew", "cask"),
})
"""Taps that ``brew`` enables by default. Never emitted as explicit ``tap`` lines."""
[docs]
def quote(value: str) -> str:
"""Ruby-compatible double-quoted string literal.
``brew bundle dump`` uses Ruby's ``String#inspect``: double quotes with backslash
escapes for control characters and unicode. ``json.dumps(..., ensure_ascii=False)``
produces the same output for ASCII content and a Ruby-parseable double-quoted
string for non-ASCII codepoints.
"""
return json.dumps(value, ensure_ascii=False)
[docs]
def format_entry(
entry_type: str,
name: str,
options: Mapping[str, object] | None = None,
) -> str:
"""Render a single Brewfile DSL line.
Supports the two shapes ``Homebrew::Bundle::Extensions::Extension.dump_entry``
emits:
- bare: ``brew "git"``
- with options: ``mas "Xcode", id: 497799835`` or
``flatpak "org.mozilla.firefox", with: ["flathub"]``
"""
line = f"{entry_type} {quote(name)}"
if not options:
return line
parts = [line]
for key, value in options.items():
if isinstance(value, list):
formatted = "[" + ", ".join(quote(v) for v in value) + "]"
elif isinstance(value, bool):
formatted = "true" if value else "false"
elif isinstance(value, int):
formatted = str(value)
else:
formatted = quote(str(value))
parts.append(f"{key}: {formatted}")
return ", ".join(parts)
[docs]
def tap_from_package_id(package_id: str) -> str | None:
"""Return ``user/tap`` if ``package_id`` is tap-qualified, else ``None``.
Default taps in :py:data:`DEFAULT_TAPS` are filtered out: those are always
enabled by ``brew`` and emitting ``tap`` lines for them would be noise.
"""
if package_id.count("/") != 2:
return None
user, tap, _ = package_id.split("/", 2)
if (user, tap) in DEFAULT_TAPS:
return None
return f"{user}/{tap}"
[docs]
def build_brewfile(
managers: Iterable[PackageManager],
*,
packages_by_manager: Mapping[str, tuple[Package, ...]] | None = None,
include_header: bool = True,
skipped_counts: Mapping[str, int] | None = None,
platform: str = "",
) -> str:
"""Render a Brewfile from the given managers' installed packages.
Only managers whose :py:attr:`~meta_package_manager.manager.PackageManager.brewfile_entry_type`
is set contribute output; the caller is expected to have filtered the iterable
accordingly, but managers without a configured entry type are silently skipped
as a defensive measure.
``packages_by_manager`` (keyed by manager id) supplies each manager's installed
packages so the caller can fetch them concurrently up front. When omitted, each
manager's :py:attr:`~meta_package_manager.manager.PackageManager.installed` is
queried inline instead (the path the unit tests exercise).
``skipped_counts`` is a per-manager-id tally of packages excluded because their
manager has no Brewfile mapping; it is rendered in the header for visibility.
"""
buckets: dict[str, list[tuple[str, Mapping[str, object] | None]]] = {
et: [] for et in BUNDLE_ENTRY_TYPES
}
tap_set: set[str] = set()
coverage: Counter[str] = Counter()
for manager in managers:
entry_type = manager.brewfile_entry_type
if entry_type is None:
continue
if packages_by_manager is None:
try:
packages: tuple[Package, ...] = tuple(manager.installed)
except Exception: # noqa: BLE001
logging.warning(
"Could not list installed packages from %s.",
manager.id,
)
continue
else:
packages = packages_by_manager.get(manager.id, ())
for pkg in packages:
entry = manager.brewfile_entry(pkg)
if entry is None:
continue
name, options = entry
buckets[entry_type].append((name, options))
coverage[manager.id] += 1
if entry_type in ("brew", "cask"):
tap = tap_from_package_id(pkg.id)
if tap is not None:
tap_set.add(tap)
for tap in sorted(tap_set):
buckets["tap"].append((tap, None))
for et in BUNDLE_ENTRY_TYPES:
buckets[et].sort(key=lambda entry: entry[0].lower())
sections: list[str] = []
if include_header:
sections.append(format_header(coverage, skipped_counts or {}, platform))
for et in BUNDLE_ENTRY_TYPES:
if not buckets[et]:
continue
section = "\n".join(format_entry(et, name, opts) for name, opts in buckets[et])
sections.append(section)
return "\n\n".join(sections) + "\n"