# 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 {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 {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 {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 {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.",
extra={"label": 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"