Source code for duck.cli.commands.sync.backends

"""
Thin wrappers around real package managers. Each backend only knows how
to check and install packages for one tool, so Duck Sync stays a thin
coordination layer instead of a package manager of its own.
"""

from __future__ import annotations

import shutil
import subprocess

from abc import ABC, abstractmethod
from pathlib import Path

from duck.cli.commands.sync.exceptions import InstallationError
from duck.logging import console


[docs] class PackageBackend(ABC): """ Base interface every system package manager wrapper implements. Args: use_sudo: Whether to prefix install commands with sudo. Backends that never use sudo (brew, choco) ignore this flag. """ name: str base_command: list[str] sudo_capable: bool = True def __init__( self, use_sudo: bool = True, on_install_started: Callable = None, on_install_finished: Callable = None, on_install_failed: Callable = None, ): self.use_sudo = use_sudo self.on_install_started_callback = on_install_started self.on_install_finished_callback = on_install_finished self.on_install_failed_callback = on_install_failed @property def install_command(self) -> list[str]: """ Returns: The install command prefix, with sudo prepended only when this backend supports it and use_sudo is enabled. """ if self.sudo_capable and self.use_sudo: return ["sudo", *self.base_command] return list(self.base_command)
[docs] @abstractmethod def is_installed(self, package: str) -> bool: """ Checks whether a package is already present on the system. Args: package: Backend-specific package name. Returns: True if the package appears to already be installed. """ pass
[docs] def on_install_started(self, package: str) -> None: """ Called immediately before a dependency installation begins. Args: package: The package about to be installed. """ self.on_install_started_callback(package)
[docs] def on_install_finished(self, package: str) -> None: """ Called after a dependency has been installed successfully. Args: package: The package that was installed. """ self.on_install_finished_callback(package)
[docs] def on_install_failed(self, package: str, reason: str) -> None: """ Called when a dependency fails to install. Args: package: The package that failed. reason: Human-readable failure reason. """ self.on_install_failed_callback(package, reason)
[docs] def install( self, package: str, extra_args: list[str] | None = None, dry_run: bool = False, ) -> None: """ Installs a package using this backend's native command. Args: package: Backend-specific package name. May contain multiple space-separated packages (e.g. "gdal-bin libgdal-dev"). extra_args: Extra flags passed straight through to the underlying package manager, e.g. ["--no-install-recommends"]. dry_run: When True, only prints the command without running it. Raises: InstallationError: If the install command exits non-zero. """ command = [*self.install_command, *(extra_args or []), *package.split()] if dry_run: console.log_raw(f"[dry-run] {' '.join(command)}", level=console.DEBUG) return # Run the install command for package self.run(command, package)
[docs] def run(self, command: list[str], package: str) -> None: """ Executes an install command and translates failures. Args: command: Full command line to run. package: Name used in the raised error for context. Raises: InstallationError: If the command fails. """ # Call on_install_started hook self.on_install_started(package) # Create command process process = subprocess.Popen( command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) if process.stdout is None: raise RuntimeError("Failed to capture subprocess stdout.") for line in process.stdout: console.log_raw(line, end="") # Log a separator blank line console.log_raw("") if process.stdout else None # Wait for process to finish execution returncode = process.wait() if returncode != 0: error = (process.stderr.strip() if process.stderr else None ) or "unknown error" # Call on_install_failed hook self.on_install_failed(package, error) # Raise the installation exception raise InstallationError(package, error) else: # Call on_install_finished hook self.on_install_finished(package)
[docs] class AptBackend(PackageBackend): """ Debian/Ubuntu package manager backend. """ name = "apt" base_command = ["apt-get", "install", "-y"]
[docs] def is_installed(self, package: str) -> bool: for single_package in package.split(): result = subprocess.run(["dpkg", "-s", single_package], capture_output=True) if result.returncode != 0: return False return True
[docs] class BrewBackend(PackageBackend): """ Homebrew backend for macOS. """ name = "brew" base_command = ["brew", "install"] sudo_capable = False
[docs] def is_installed(self, package: str) -> bool: for single_package in package.split(): result = subprocess.run(["brew", "list", single_package], capture_output=True) if result.returncode != 0: return False return True
[docs] class DnfBackend(PackageBackend): """ Fedora/RHEL package manager backend. """ name = "dnf" base_command = ["dnf", "install", "-y"]
[docs] def is_installed(self, package: str) -> bool: for single_package in package.split(): result = subprocess.run(["rpm", "-q", single_package], capture_output=True) if result.returncode != 0: return False return True
[docs] class PacmanBackend(PackageBackend): """ Arch Linux package manager backend. """ name = "pacman" base_command = ["pacman", "-S", "--noconfirm"]
[docs] def is_installed(self, package: str) -> bool: for single_package in package.split(): result = subprocess.run(["pacman", "-Q", single_package], capture_output=True) if result.returncode != 0: return False return True
[docs] class ChocoBackend(PackageBackend): """ Chocolatey backend for Windows. """ name = "choco" base_command = ["choco", "install", "-y"] sudo_capable = False
[docs] def is_installed(self, package: str) -> bool: for single_package in package.split(): result = subprocess.run( ["choco", "list", "--local-only", single_package], capture_output=True, text=True, ) if single_package.lower() not in result.stdout.lower(): return False return True
[docs] class TermuxBackend(PackageBackend): """ Backend for Termux on Android, which wraps apt/dpkg through its own pkg command and runs as an unprivileged user with no sudo binary. """ name = "termux" base_command = ["pkg", "install", "-y"] sudo_capable = False
[docs] def is_installed(self, package: str) -> bool: for single_package in package.split(): result = subprocess.run(["dpkg", "-s", single_package], capture_output=True) if result.returncode != 0: return False return True
[docs] class PythonBackend(PackageBackend): """ Installs python packages, preferring uv when present since it is faster and already used across Duck Framework projects. """ name = "python" def __init__(self, use_sudo: bool = False, **kwargs): super().__init__(use_sudo=False, **kwargs) self.tool = "uv" if shutil.which("uv") else "pip" @property def base_command(self) -> list[str]: """ Returns: The install command prefix for whichever tool is active, e.g. ["uv", "pip", "install"] or ["pip", "install"]. """ return ["uv", "pip", "install"] if self.tool == "uv" else ["pip", "install"]
[docs] def is_installed(self, package: str) -> bool: """ Check whether a Python package is installed and satisfies the requested version constraint. Args: package: Package requirement string, e.g. ``"django>=5.0"`` or ``"requests[socks]"``. Returns: True if the package is installed and satisfies the requirement. """ from importlib import metadata from packaging.requirements import Requirement try: requirement = Requirement(package) distribution = metadata.distribution(requirement.name) except (metadata.PackageNotFoundError, ValueError): return False # No version constraint means existence is enough if not requirement.specifier: return True return distribution.version in requirement.specifier
[docs] def install_requirements( self, path: Path, extra_args: list[str] | None = None, dry_run: bool = False, ) -> None: """ Installs every package listed in a requirements.txt file. Args: path: Resolved path to the requirements file. extra_args: Extra flags such as ["--no-cache-dir"]. dry_run: When True, only prints the command without running it. Raises: InstallationError: If the install command exits non-zero. """ command = [*self.base_command, "-r", str(path), *(extra_args or [])] if dry_run: console.log_raw(f"[dry-run] {' '.join(command)}", level=console.DEBUG) return # Run the command self.run(command, package=str(path))
[docs] class CustomBackend(PackageBackend): """ Escape hatch for package managers not natively supported by Duck Sync. Accepts any install command string such as ``"nix-env -iA nixpkgs"`` or ``"some_command -i"``. The package name is appended as a trailing positional argument when :meth:`install` is called. ``is_installed`` always returns ``False`` so every package is attempted on every run. Override ``is_installed`` by subclassing if you need a smarter check. Args: command: Shell-style install command prefix, e.g. ``"brew install"`` or ``"some_command -i"``. Split with :func:`shlex.split`. use_sudo: Whether to prepend ``sudo`` to the resolved command. Only applied when :attr:`sudo_capable` is ``True`` on this instance. sudo_capable: Whether the custom command supports sudo. Defaults to ``True`` so callers can opt in or out explicitly. """ name = "custom" def __init__( self, command: str, use_sudo: bool = False, sudo_capable: bool = True, **kwargs, ): import shlex as _shlex super().__init__(use_sudo=use_sudo, **kwargs) self.sudo_capable = sudo_capable self.base_command = _shlex.split(command)
[docs] def is_installed(self, package: str) -> bool: """ Args: package: Package name (unused; always returns False). Returns: Always ``False``; the custom backend never skips an install. """ return False
# Register backends BACKEND_REGISTRY: dict[str, type[PackageBackend]] = { "apt": AptBackend, "brew": BrewBackend, "dnf": DnfBackend, "pacman": PacmanBackend, "choco": ChocoBackend, "termux": TermuxBackend, "python": PythonBackend, }
[docs] def get_backend(name: str, use_sudo: bool = True, **backend_kwargs) -> PackageBackend: """ Instantiates a system package backend by name or custom command string. When `name` is a key in `BACKEND_REGISTRY` the matching built-in backend is returned. Otherwise the value is treated as a raw shell-style install command and wrapped in a `CustomBackend`, letting callers force any arbitrary tool: ```python get_backend("apt") # built-in AptBackend get_backend("brew") # built-in BrewBackend get_backend("some_command -i") # CustomBackend("some_command -i") get_backend("nix-env -iA nixpkgs") # CustomBackend("nix-env -iA nixpkgs") ``` Args: name: Backend identifier (``"apt"``, ``"brew"`` …) **or** a shell-style install command prefix (``"some_command -i"``). use_sudo: Whether install commands should be prefixed with sudo. Ignored by built-in backends that are never sudo-capable, and forwarded to :class:`CustomBackend` for raw commands. **backend_kwargs: Extra keyword arguments to provide to backend class. Returns: A ready-to-use :class:`PackageBackend` instance. """ if name in BACKEND_REGISTRY: return BACKEND_REGISTRY[name](use_sudo=use_sudo, **backend_kwargs) # Return the custom backend. return CustomBackend(command=name, use_sudo=use_sudo, **backend_kwargs)