Source code for meta_package_manager.brewfile

# 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 format_header( coverage: Mapping[str, int], skipped: Mapping[str, int], platform: str, ) -> str: """Render the comment block at the top of a Brewfile dump.""" timestamp = datetime.now(tz=timezone.utc).isoformat(timespec="seconds") coverage_line = ( ", ".join(f"{mid}={n}" for mid, n in sorted(coverage.items())) or "(empty)" ) skipped_line = ( ", ".join(f"{mid}={n}" for mid, n in sorted(skipped.items())) or "(none)" ) return ( f"# Brewfile generated by mpm v{__version__} on {timestamp}.\n" f"# Source platform: {platform or 'unknown'}.\n" "#\n" "# WARNING: partial view. Only managers brew bundle supports natively are\n" "# included (brew, cask, mas, vscode, npm, cargo, uv, winget, flatpak).\n" "# Packages from apt, dnf, pacman, pip, pipx, gem, snap, scoop, chocolatey,\n" "# sdkman, etc. are NOT in this file.\n" "#\n" "# DO NOT run `brew bundle cleanup --force` against this file. It would\n" "# treat every excluded package as 'should be uninstalled' and tear down\n" "# tools managed by the excluded managers.\n" "#\n" f"# Coverage: {coverage_line}\n" f"# Skipped: {skipped_line}" )
[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"