From f7c40d48c0727a96843c85990cc36ae5a9ac6888 Mon Sep 17 00:00:00 2001
From: Joel Grunbaum <joelgrun@gmail.com>
Date: Sat, 07 Feb 2026 23:42:43 +0000
Subject: [PATCH] Add integration test for binary
---
src/archbuild/builder.py | 460 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 460 insertions(+), 0 deletions(-)
diff --git a/src/archbuild/builder.py b/src/archbuild/builder.py
new file mode 100644
index 0000000..597f495
--- /dev/null
+++ b/src/archbuild/builder.py
@@ -0,0 +1,460 @@
+"""Package builder with parallel execution and proper locking."""
+
+import asyncio
+import fcntl
+import os
+import shutil
+import subprocess
+from dataclasses import dataclass, field
+from datetime import datetime
+from enum import Enum
+from pathlib import Path
+from concurrent.futures import ProcessPoolExecutor
+from typing import Any
+
+from archbuild.aur import AURClient
+from archbuild.config import Config, PackageOverride
+from archbuild.logging import get_logger
+from archbuild.resolver import DependencyResolver
+
+logger = get_logger("builder")
+
+
+class BuildStatus(Enum):
+ """Build status for a package."""
+
+ PENDING = "pending"
+ BUILDING = "building"
+ SUCCESS = "success"
+ FAILED = "failed"
+ SKIPPED = "skipped"
+
+
+@dataclass
+class BuildResult:
+ """Result of a package build."""
+
+ package: str
+ status: BuildStatus
+ version: str | None = None
+ duration: float = 0.0
+ error: str | None = None
+ artifacts: list[Path] = field(default_factory=list)
+ timestamp: datetime = field(default_factory=datetime.now)
+
+
+class FileLock:
+ """Context manager for file-based locking using flock."""
+
+ def __init__(self, path: Path):
+ """Initialize lock on file path.
+
+ Args:
+ path: Path to lock file
+ """
+ self.path = path
+ self.fd: int | None = None
+
+ def __enter__(self) -> "FileLock":
+ """Acquire exclusive lock."""
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ self.fd = os.open(str(self.path), os.O_RDWR | os.O_CREAT)
+ fcntl.flock(self.fd, fcntl.LOCK_EX)
+ logger.debug(f"Acquired lock: {self.path}")
+ return self
+
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
+ """Release lock."""
+ if self.fd is not None:
+ fcntl.flock(self.fd, fcntl.LOCK_UN)
+ os.close(self.fd)
+ logger.debug(f"Released lock: {self.path}")
+
+
+def _run_makepkg(
+ package_dir: Path,
+ sign: bool = False,
+ key: str = "",
+ clean: bool = True,
+ skip_checksums: bool = False,
+ extra_args: list[str] | None = None,
+ env_overrides: dict[str, str] | None = None,
+) -> tuple[bool, str, list[Path]]:
+ """Run makepkg in a subprocess.
+
+ This runs in a separate process for parallelization.
+
+ Args:
+ package_dir: Directory containing PKGBUILD
+ sign: Whether to sign packages
+ key: GPG key for signing
+ clean: Clean build directory after build
+ skip_checksums: Skip checksum verification
+ extra_args: Additional makepkg arguments
+ env_overrides: Environment variable overrides
+
+ Returns:
+ Tuple of (success, error_message, artifact_paths)
+ """
+ cmd = ["makepkg", "-s", "--noconfirm"]
+
+ if clean:
+ cmd.append("-c")
+ if sign and key:
+ cmd.extend(["--sign", "--key", key])
+ if skip_checksums:
+ cmd.append("--skipchecksums")
+ if extra_args:
+ cmd.extend(extra_args)
+
+ env = os.environ.copy()
+ if env_overrides:
+ env.update(env_overrides)
+
+ try:
+ result = subprocess.run(
+ cmd,
+ cwd=package_dir,
+ capture_output=True,
+ text=True,
+ env=env,
+ timeout=3600, # 1 hour timeout
+ )
+
+ if result.returncode != 0:
+ return False, result.stderr or result.stdout, []
+
+ # Find built packages
+ artifacts = list(package_dir.glob("*.pkg.tar.*"))
+ artifacts = [a for a in artifacts if not a.name.endswith(".sig")]
+
+ return True, "", artifacts
+
+ except subprocess.TimeoutExpired:
+ return False, "Build timed out after 1 hour", []
+ except Exception as e:
+ return False, str(e), []
+
+
+class Builder:
+ """Package builder with parallel execution support."""
+
+ def __init__(
+ self,
+ config: Config,
+ aur_client: AURClient,
+ ):
+ """Initialize builder.
+
+ Args:
+ config: Application configuration
+ aur_client: AUR client for package info
+ """
+ self.config = config
+ self.aur_client = aur_client
+ self.resolver = DependencyResolver(aur_client)
+ self._lock_dir = config.repository.build_dir / ".locks"
+ self._executor: ProcessPoolExecutor | None = None
+
+ async def __aenter__(self) -> "Builder":
+ """Async context manager entry."""
+ if self.config.building.parallel:
+ max_workers = self.config.building.max_workers
+ self._executor = ProcessPoolExecutor(max_workers=max_workers)
+ logger.info(f"Builder initialized with {max_workers} workers (parallel)")
+ else:
+ self._executor = None
+ logger.info("Builder initialized (sequential)")
+ return self
+
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
+ """Async context manager exit."""
+ if self._executor:
+ self._executor.shutdown(wait=True)
+ self._executor = None
+
+ def _get_lock_path(self, package: str) -> Path:
+ """Get lock file path for package.
+
+ Args:
+ package: Package name
+
+ Returns:
+ Path to lock file
+ """
+ return self._lock_dir / f"{package}.lock"
+
+ def _get_package_dir(self, package: str) -> Path:
+ """Get build directory for package.
+
+ Args:
+ package: Package name
+
+ Returns:
+ Path to package build directory
+ """
+ return self.config.repository.build_dir / package
+
+ def _get_override(self, package: str) -> PackageOverride:
+ """Get package-specific overrides.
+
+ Args:
+ package: Package name
+
+ Returns:
+ PackageOverride (default if not specified)
+ """
+ # Check for package-specific override first, then fall back to _default
+ if package in self.config.package_overrides:
+ return self.config.package_overrides[package]
+ return self.config.package_overrides.get("_default", PackageOverride())
+
+ async def _clone_or_update(self, package: str) -> bool:
+ """Clone or update package from AUR.
+
+ Args:
+ package: Package name
+
+ Returns:
+ True if there were updates (or new clone)
+ """
+ pkg_dir = self._get_package_dir(package)
+
+ if pkg_dir.exists():
+ # Update existing repo
+ result = subprocess.run(
+ ["git", "reset", "--hard"],
+ cwd=pkg_dir,
+ capture_output=True,
+ )
+ result = subprocess.run(
+ ["git", "pull"],
+ cwd=pkg_dir,
+ capture_output=True,
+ text=True,
+ )
+ return "Already up to date" not in result.stdout
+ else:
+ # Clone new repo
+ pkg_info = await self.aur_client.get_package(package)
+ if not pkg_info:
+ raise ValueError(f"Package not found in AUR: {package}")
+
+ pkg_dir.parent.mkdir(parents=True, exist_ok=True)
+ subprocess.run(
+ ["git", "clone", pkg_info.git_url, str(pkg_dir)],
+ check=True,
+ capture_output=True,
+ )
+ return True
+
+ def _is_vcs_package(self, package_dir: Path) -> bool:
+ """Check if package is a VCS package (needs rebuild on each run).
+
+ Args:
+ package_dir: Path to package directory
+
+ Returns:
+ True if VCS package
+ """
+ pkgbuild = package_dir / "PKGBUILD"
+ if not pkgbuild.exists():
+ return False
+
+ content = pkgbuild.read_text()
+ return "pkgver()" in content
+
+ async def build_package(
+ self,
+ package: str,
+ force: bool = False,
+ ) -> BuildResult:
+ """Build a single package.
+
+ Args:
+ package: Package name
+ force: Force rebuild even if up to date
+
+ Returns:
+ BuildResult with status and artifacts
+ """
+ start_time = datetime.now()
+ pkg_dir = self._get_package_dir(package)
+ override = self._get_override(package)
+
+ logger.info(f"Building package: {package}")
+
+ try:
+ # Clone or update
+ has_updates = await self._clone_or_update(package)
+ is_vcs = self._is_vcs_package(pkg_dir)
+
+ # Skip if no updates and not forced
+ if not has_updates and not is_vcs and not force:
+ logger.info(f"Skipping {package}: already up to date")
+ return BuildResult(
+ package=package,
+ status=BuildStatus.SKIPPED,
+ duration=(datetime.now() - start_time).total_seconds(),
+ )
+
+ # Run build with retries
+ last_error = ""
+ for attempt in range(self.config.building.retry_attempts):
+ if attempt > 0:
+ delay = self.config.building.retry_delay * (2 ** (attempt - 1))
+ logger.warning(
+ f"Retrying {package} (attempt {attempt + 1}/"
+ f"{self.config.building.retry_attempts}) after {delay}s"
+ )
+ await asyncio.sleep(delay)
+
+ # Run makepkg in executor
+ loop = asyncio.get_event_loop()
+ success, error, artifacts = await loop.run_in_executor(
+ self._executor,
+ _run_makepkg,
+ pkg_dir,
+ self.config.signing.enabled,
+ self.config.signing.key,
+ self.config.building.clean,
+ override.skip_checksums,
+ override.extra_args,
+ override.env,
+ )
+
+ if success:
+ duration = (datetime.now() - start_time).total_seconds()
+ logger.info(f"Successfully built {package} in {duration:.1f}s")
+ return BuildResult(
+ package=package,
+ status=BuildStatus.SUCCESS,
+ duration=duration,
+ artifacts=artifacts,
+ )
+
+ last_error = error
+
+ # All retries failed
+ duration = (datetime.now() - start_time).total_seconds()
+ logger.error(f"Failed to build {package}: {last_error}")
+ return BuildResult(
+ package=package,
+ status=BuildStatus.FAILED,
+ duration=duration,
+ error=last_error,
+ )
+
+ except Exception as e:
+ duration = (datetime.now() - start_time).total_seconds()
+ logger.exception(f"Error building {package}")
+ return BuildResult(
+ package=package,
+ status=BuildStatus.FAILED,
+ duration=duration,
+ error=str(e),
+ )
+
+ async def build_all(
+ self,
+ force: bool = False,
+ ) -> list[BuildResult]:
+ """Build all packages in build directory.
+
+ Args:
+ force: Force rebuild all packages
+
+ Returns:
+ List of build results
+ """
+ # Update system if configured
+ if self.config.building.update_system:
+ logger.info("Updating system...")
+ subprocess.run(
+ ["sudo", "pacman", "-Syu", "--noconfirm"],
+ check=False,
+ )
+
+ # Find all packages
+ build_dir = self.config.repository.build_dir
+ packages = [
+ d.name for d in build_dir.iterdir()
+ if d.is_dir() and not d.name.startswith(".")
+ ]
+
+ if not packages:
+ logger.warning("No packages found in build directory")
+ return []
+
+ logger.info(f"Building {len(packages)} packages")
+
+ # Build in parallel or sequentially
+ if self.config.building.parallel:
+ tasks = [self.build_package(pkg, force) for pkg in packages]
+ results = await asyncio.gather(*tasks)
+ else:
+ results = []
+ for pkg in packages:
+ result = await self.build_package(pkg, force)
+ results.append(result)
+
+ # Summary
+ success = sum(1 for r in results if r.status == BuildStatus.SUCCESS)
+ failed = sum(1 for r in results if r.status == BuildStatus.FAILED)
+ skipped = sum(1 for r in results if r.status == BuildStatus.SKIPPED)
+
+ logger.info(f"Build complete: {success} succeeded, {failed} failed, {skipped} skipped")
+
+ return list(results)
+
+ async def add_package(self, package: str) -> BuildResult:
+ """Add and build a new package with dependencies.
+
+ Args:
+ package: Package name
+
+ Returns:
+ BuildResult for the main package
+ """
+ logger.info(f"Adding package: {package}")
+
+ # Resolve dependencies
+ build_order = await self.resolver.resolve([package])
+
+ # Build dependencies first
+ results: list[BuildResult] = []
+ for dep in build_order:
+ if dep != package:
+ logger.info(f"Building dependency: {dep}")
+ result = await self.build_package(dep, force=True)
+ results.append(result)
+
+ if result.status == BuildStatus.FAILED:
+ logger.error(f"Dependency {dep} failed, aborting")
+ return BuildResult(
+ package=package,
+ status=BuildStatus.FAILED,
+ error=f"Dependency {dep} failed to build",
+ )
+
+ # Build main package
+ return await self.build_package(package, force=True)
+
+ def remove_package(self, package: str) -> bool:
+ """Remove a package from the build directory.
+
+ Args:
+ package: Package name
+
+ Returns:
+ True if removed successfully
+ """
+ pkg_dir = self._get_package_dir(package)
+
+ if pkg_dir.exists():
+ shutil.rmtree(pkg_dir)
+ logger.info(f"Removed package: {package}")
+ return True
+
+ logger.warning(f"Package not found: {package}")
+ return False
--
Gitblit v1.10.0