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/config.py |  202 ++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 202 insertions(+), 0 deletions(-)

diff --git a/src/archbuild/config.py b/src/archbuild/config.py
new file mode 100644
index 0000000..74a09c3
--- /dev/null
+++ b/src/archbuild/config.py
@@ -0,0 +1,202 @@
+"""Configuration loading and validation using Pydantic."""
+
+from pathlib import Path
+from typing import Any
+
+import yaml
+from pydantic import BaseModel, Field, field_validator
+
+
+class RepositoryConfig(BaseModel):
+    """Repository settings."""
+
+    name: str = Field(..., description="Repository name")
+    path: Path = Field(..., description="Path to repository directory")
+    build_dir: Path = Field(..., description="Path to build directory")
+    compression: str = Field(default="zst", description="Compression format")
+
+
+class BuildingConfig(BaseModel):
+    """Build settings."""
+
+    parallel: bool = Field(default=True, description="Enable parallel builds")
+    max_workers: int = Field(default=4, ge=1, le=32, description="Maximum parallel workers")
+    clean: bool = Field(default=True, description="Clean build directory after build")
+    update_system: bool = Field(default=False, description="Update system before building")
+    retry_attempts: int = Field(default=3, ge=1, le=10, description="Retry attempts on failure")
+    retry_delay: int = Field(default=5, ge=1, description="Base delay between retries (seconds)")
+
+
+class SigningConfig(BaseModel):
+    """Package signing settings."""
+
+    enabled: bool = Field(default=False, description="Enable package signing")
+    key: str = Field(default="", description="GPG key ID for signing")
+
+
+class EmailConfig(BaseModel):
+    """Email notification settings."""
+
+    enabled: bool = Field(default=False, description="Enable email notifications")
+    to: str = Field(default="", description="Recipient email address")
+    from_addr: str = Field(default="", alias="from", description="Sender email address")
+    smtp_host: str = Field(default="localhost", description="SMTP server host")
+    smtp_port: int = Field(default=25, description="SMTP server port")
+    use_tls: bool = Field(default=False, description="Use TLS for SMTP")
+    username: str = Field(default="", description="SMTP username")
+    password: str = Field(default="", description="SMTP password")
+
+
+class WebhookConfig(BaseModel):
+    """Webhook notification settings (extensible for future use)."""
+
+    enabled: bool = Field(default=False, description="Enable webhook notifications")
+    url: str = Field(default="", description="Webhook URL")
+    type: str = Field(default="generic", description="Webhook type (generic, discord, slack)")
+
+
+class NotificationsConfig(BaseModel):
+    """Notification settings."""
+
+    email: EmailConfig = Field(default_factory=EmailConfig)
+    webhooks: list[WebhookConfig] = Field(default_factory=list)
+
+
+class PackageRetentionConfig(BaseModel):
+    """Package retention settings."""
+
+    keep_versions: int = Field(default=3, ge=1, le=100, description="Number of old versions to keep")
+    cleanup_on_build: bool = Field(default=True, description="Clean old versions after build")
+
+
+class PackageOverride(BaseModel):
+    """Per-package build overrides."""
+
+    skip_checksums: bool = Field(default=False, description="Skip checksum verification")
+    extra_args: list[str] = Field(default_factory=list, description="Extra makepkg arguments")
+    env: dict[str, str] = Field(default_factory=dict, description="Environment variables")
+
+
+class Config(BaseModel):
+    """Main configuration model."""
+
+    repository: RepositoryConfig
+    building: BuildingConfig = Field(default_factory=BuildingConfig)
+    signing: SigningConfig = Field(default_factory=SigningConfig)
+    notifications: NotificationsConfig = Field(default_factory=NotificationsConfig)
+    retention: PackageRetentionConfig = Field(default_factory=PackageRetentionConfig)
+    package_overrides: dict[str, PackageOverride] = Field(
+        default_factory=dict, description="Per-package overrides"
+    )
+    log_level: str = Field(default="INFO", description="Logging level")
+    log_file: Path | None = Field(default=None, description="Optional log file path")
+
+    @field_validator("log_level")
+    @classmethod
+    def validate_log_level(cls, v: str) -> str:
+        valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
+        if v.upper() not in valid_levels:
+            raise ValueError(f"Invalid log level: {v}. Must be one of {valid_levels}")
+        return v.upper()
+
+
+def load_config(path: Path) -> Config:
+    """Load and validate configuration from YAML file.
+
+    Args:
+        path: Path to configuration file
+
+    Returns:
+        Validated Config object
+
+    Raises:
+        FileNotFoundError: If config file doesn't exist
+        ValidationError: If config is invalid
+    """
+    if not path.exists():
+        raise FileNotFoundError(f"Configuration file not found: {path}")
+
+    with open(path) as f:
+        data = yaml.safe_load(f)
+
+    return Config.model_validate(data)
+
+
+def migrate_vars_sh(vars_path: Path) -> dict[str, Any]:
+    """Migrate old vars.sh to new config format.
+
+    Args:
+        vars_path: Path to vars.sh file
+
+    Returns:
+        Dictionary suitable for Config.model_validate()
+    """
+    variables: dict[str, str] = {}
+
+    with open(vars_path) as f:
+        for line in f:
+            line = line.strip()
+            if line and not line.startswith("#") and "=" in line:
+                # Handle export statements
+                if line.startswith("export "):
+                    line = line[7:]
+                key, _, value = line.partition("=")
+                # Remove quotes
+                value = value.strip("'\"")
+                variables[key] = value
+
+    # Convert to new format
+    config: dict[str, Any] = {
+        "repository": {
+            "name": variables.get("REPONAME", ""),
+            "path": variables.get("REPODIR", "/repo/x86_64"),
+            "build_dir": variables.get("BUILDDIR", "/repo/build"),
+            "compression": variables.get("COMPRESSION", "zst"),
+        },
+        "building": {
+            "parallel": variables.get("PARALLEL", "N") == "Y",
+            "clean": variables.get("CLEAN", "N") == "Y",
+            "update_system": variables.get("UPDATE", "N") == "Y",
+        },
+        "signing": {
+            "enabled": variables.get("SIGN", "N") == "Y",
+            "key": variables.get("KEY", ""),
+        },
+        "notifications": {
+            "email": {
+                "enabled": bool(variables.get("TO_EMAIL")),
+                "to": variables.get("TO_EMAIL", ""),
+                "from": variables.get("FROM_EMAIL", ""),
+            }
+        },
+        "retention": {
+            "keep_versions": int(variables.get("NUM_OLD", "5")),
+        },
+    }
+
+    return config
+
+
+def save_config(config: Config, path: Path) -> None:
+    """Save configuration to YAML file.
+
+    Args:
+        config: Config object to save
+        path: Path to save configuration file
+    """
+    data = config.model_dump(by_alias=True, exclude_none=True)
+    
+    # Convert Path objects to strings for YAML
+    def convert_paths(obj: Any) -> Any:
+        if isinstance(obj, Path):
+            return str(obj)
+        elif isinstance(obj, dict):
+            return {k: convert_paths(v) for k, v in obj.items()}
+        elif isinstance(obj, list):
+            return [convert_paths(v) for v in obj]
+        return obj
+
+    data = convert_paths(data)
+
+    with open(path, "w") as f:
+        yaml.dump(data, f, default_flow_style=False, sort_keys=False)

--
Gitblit v1.10.0