Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ concurrency:

jobs:
check:
name: lint, format & type check
name: lint, format, type & layer check
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
Expand All @@ -28,7 +28,7 @@ jobs:
- name: Sync dependencies
run: uv sync --all-extras

- name: Lint, format & type check
- name: Lint, format, type & layer check
run: uv run just check

- name: Fail on uncommitted formatting changes
Expand Down
7 changes: 7 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ repos:
entry: uv run ty check src tests
language: system
types: [python]
# Layering
- id: import-linter
name: Check import layering
entry: uv run lint-imports
language: system
pass_filenames: false
types: [python]
# Syntax Checking
- id: python-syntax-check
name: Check for Python syntax errors
Expand Down
6 changes: 5 additions & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,12 @@ format:
type:
uv run ty check src tests

# Check the one-way layering rule
layers:
uv run lint-imports

# Check code quality
check: lint format type
check: lint format type layers

# Run all pre-commit hooks
pre:
Expand Down
27 changes: 27 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ br = "brewery.cli.main:main"
[project.optional-dependencies]
dev = [
"icecream>=2.2.0,<3.0.0",
"import-linter>=2.3,<3.0.0",
"prek>=0.4.14,<1.0.0",
"pytest>=9.0.3,<10.0.0",
"pytest-asyncio>=1.4.0,<2.0.0",
Expand All @@ -72,6 +73,32 @@ Changelog = "https://github.com/rdawebb/brewery/blob/main/CHANGELOG.md"
[tool.uv.build-backend]
module-name = "brewery"

[tool.importlinter]
root_packages = ["brewery"]

# One-way layering rule: a package may import anything below it, never above
[[tool.importlinter.contracts]]
name = "Layers"
type = "layers"
containers = ["brewery"]
layers = [
"cli",
"daemon",
"services",
"providers",
"core",
]
exhaustive = true
exhaustive_ignores = ["scripts"]

# Repository is a data facade for the layers above it; providers build on the
# core primitives directly
[[tool.importlinter.contracts]]
name = "Providers use core primitives, not the Repository facade"
type = "forbidden"
source_modules = ["brewery.providers"]
forbidden_modules = ["brewery.core.repo"]

[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
Expand Down
42 changes: 0 additions & 42 deletions src/brewery/analysis/status.py

This file was deleted.

3 changes: 2 additions & 1 deletion src/brewery/cli/commands/cleanup.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from brewery.cli.context import _repository, app, console, run_async
from brewery.cli.error_formatting import CommandFailed, command_error
from brewery.cli.output import print_failures, print_result, spinner
from brewery.services.cleanup import cleanup_packages


@app.command(name="cleanup", aliases=["c", "clean"])
Expand All @@ -16,7 +17,7 @@ def cleanup() -> None:
with _repository() as repo:
sys.stdout.write("\n")
with spinner("Cleaning up..."):
removed, failures = run_async(coro=repo.cleanup_packages())
removed, failures = run_async(coro=cleanup_packages(repo))

if not removed and not failures:
console.print("✓ Nothing to clean up\n", style="bold green")
Expand Down
3 changes: 2 additions & 1 deletion src/brewery/cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from brewery.cli.progress import make_reporter
from brewery.core.errors import AlreadyInstalledWarning
from brewery.core.models import PackageKind
from brewery.services.install import install_packages


@app.command(aliases=["add"])
Expand Down Expand Up @@ -48,7 +49,7 @@ def install(

with _repository() as repo:
installed, failures = run_async(
coro=repo.install_packages(names, target, progress=make_reporter(console))
coro=install_packages(repo, names, target, progress=make_reporter(console))
)

print_result(
Expand Down
11 changes: 7 additions & 4 deletions src/brewery/cli/commands/link.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
print_failures,
print_result,
)
from brewery.services import link as link_service

if TYPE_CHECKING:
from brewery.providers.linker import LinkResult
Expand All @@ -21,7 +22,7 @@ def _preview_link(linked: list[tuple[str, LinkResult]]) -> None:
"""Print the paths a real `link` would create and, with --overwrite, delete.

Args:
linked: The (name, result) pairs reported by the repository.
linked: The (name, result) pairs reported by the link service.
"""
for name, result in linked:
print_result(
Expand Down Expand Up @@ -62,8 +63,8 @@ def link(
"""
with _repository() as repo:
sys.stdout.write("\n")
linked, advisories, failures = repo.link_packages(
names, overwrite=overwrite, force=force, dry_run=dry_run
linked, advisories, failures = link_service.link_packages(
repo, names, overwrite=overwrite, force=force, dry_run=dry_run
)

print_advisories(advisories)
Expand Down Expand Up @@ -104,7 +105,9 @@ def unlink(
"""
with _repository() as repo:
sys.stdout.write("\n")
unlinked, advisories, failures = repo.unlink_packages(names, dry_run=dry_run)
unlinked, advisories, failures = link_service.unlink_packages(
repo, names, dry_run=dry_run
)

print_advisories(advisories)

Expand Down
5 changes: 3 additions & 2 deletions src/brewery/cli/commands/pin.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
print_result,
)
from brewery.core.errors import UserError
from brewery.services import pin as pin_service


def _reject_casks(cask: bool, command: str, names: list[str]) -> None:
Expand Down Expand Up @@ -77,7 +78,7 @@ def pin(

with _repository() as repo:
sys.stdout.write("\n")
pinned, advisories, failures = repo.pin_packages(names)
pinned, advisories, failures = pin_service.pin_packages(repo, names)

_report("pin", pinned, advisories, failures)

Expand All @@ -102,7 +103,7 @@ def unpin(

with _repository() as repo:
sys.stdout.write("\n")
unpinned, advisories, failures = repo.unpin_packages(names)
unpinned, advisories, failures = pin_service.unpin_packages(repo, names)

_report("unpin", unpinned, advisories, failures)

Expand Down
3 changes: 2 additions & 1 deletion src/brewery/cli/commands/uninstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
spinner,
)
from brewery.core.models import PackageKind
from brewery.services.uninstall import uninstall_packages


@app.command(aliases=["rm", "del"])
Expand Down Expand Up @@ -41,7 +42,7 @@ def uninstall(

with _repository() as repo:
with spinner("Uninstalling..."):
removed, failures = run_async(coro=repo.uninstall_packages(names, kind))
removed, failures = run_async(coro=uninstall_packages(repo, names, kind))

print_result(
f"✓ Uninstalled {len(removed)} package(s)\n", removed, style="bold green"
Expand Down
3 changes: 2 additions & 1 deletion src/brewery/cli/commands/upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from brewery.cli.progress import make_reporter
from brewery.core.errors import PinnedPackageWarning
from brewery.core.models import Package, PackageKind
from brewery.services.upgrade import upgrade_packages


@app.command(aliases=["u", "up"])
Expand Down Expand Up @@ -71,7 +72,7 @@ def upgrade(
return

upgraded, current, advisories, failures = run_async(
coro=repo.upgrade_packages(names, kind, progress=make_reporter(console))
coro=upgrade_packages(repo, names, kind, progress=make_reporter(console))
)

if not upgraded and not advisories and not failures and not current:
Expand Down
38 changes: 38 additions & 0 deletions src/brewery/core/deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Dependency queries over already-merged installed packages."""

from __future__ import annotations

from brewery.core.models import Package, PackageKind


def blocking_dependents(
packages: list[Package], removal: set[str]
) -> dict[str, list[str]]:
"""Installed formulae outside `removal` that still require a target.

Reads each target's receipt-derived reverse-deps and drops any dependent
that is itself being removed in the same batch.

Args:
packages: Installed packages to search; non-formulae are ignored.
removal: Canonical formula names slated for removal.

Returns:
target -> sorted installed formulae that require it (empty if none).
"""
if not removal:
return {}

installed = {p.name: p for p in packages if p.kind == PackageKind.FORMULA}

blockers: dict[str, list[str]] = {}
for name in removal:
pkg = installed.get(name)
if pkg is None:
continue

deps = sorted(d for d in pkg.used_by if d not in removal)
if deps:
blockers[name] = deps

return blockers
39 changes: 38 additions & 1 deletion src/brewery/core/merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

from brewery.analysis.status import derive_local_status
from brewery.core.catalog import CaskRow, Catalog, FormulaRow
from brewery.core.fs_state import InstalledRecord
from brewery.core.models import (
Expand All @@ -14,6 +13,44 @@
from brewery.core.version import PkgVersion


def derive_local_status(
*,
kind: PackageKind,
head: bool = False,
linked: bool = True,
pinned: bool = False,
) -> PackageStatus:
"""Derive the filesystem-knowable half of a package's status.

Returns only the flags the installed state can answer via filesystem state;
the merge layers the catalog-knowable flags on top.
Keyword-only by design: the three flags are all booleans.

Args:
kind: The package kind. Local flags apply to formulae only.
head: Whether the active keg is a HEAD build.
linked: Whether the formula is linked into the prefix. Defaults to True
so that a formula is not falsely flagged `NOT_LINKED`.
pinned: Whether the formula is pinned.

Returns:
The locally-derived PackageStatus.
"""
status: PackageStatus = PackageStatus.NONE

if kind == PackageKind.FORMULA:
if pinned:
status |= PackageStatus.PINNED

if head:
status |= PackageStatus.HEAD

if not linked:
status |= PackageStatus.NOT_LINKED

return status


def merge_one(record: InstalledRecord, catalog: Catalog) -> Package:
"""Join a single installed record against the catalog into a Package.

Expand Down
4 changes: 4 additions & 0 deletions src/brewery/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
from enum import Enum, Flag, auto
from typing import Any

# (name, reason) pairs -- every service reports its advisories and failures this
# way, and the CLI's exit-code mapping keys off whether the failure list is empty
Notes = list[tuple[str, str]]


class PackageKind(Enum):
"""Enumeration of package kinds."""
Expand Down
Loading
Loading