#!/usr/bin/env python3 """ Utility functions for the build system. Provides common operations like directory creation, process execution, file dependency checking, and more. """ import subprocess import shutil from pathlib import Path from typing import List, Union, Optional import sys # ----------------------------------------------------------------------------- # Directory operations # ----------------------------------------------------------------------------- def mkdir(path: Path) -> None: """ Create a directory and all parent directories if they don't exist. Args: path: Directory path to create """ path.mkdir(parents=True, exist_ok=True) def rmdir(path: Path, ignore_errors: bool = True) -> None: """ Recursively remove a directory and all its contents. Args: path: Directory path to remove ignore_errors: If True, ignore errors during removal """ if path.exists(): shutil.rmtree(path, ignore_errors=ignore_errors) # ----------------------------------------------------------------------------- # Process execution # ----------------------------------------------------------------------------- def run(cmd: List[Union[str, Path]], cwd: Optional[Path] = None, check: bool = True, capture_output: bool = False, verbose: bool = True) -> subprocess.CompletedProcess: """ Execute a command and optionally capture its output. Args: cmd: Command and arguments to execute cwd: Working directory for the command check: If True, raise exception on non-zero exit code capture_output: If True, capture stdout and stderr verbose: If True, print the command before executing Returns: CompletedProcess instance with return code and output Raises: subprocess.CalledProcessError: If check=True and command fails """ # Convert all Path objects to strings cmd_str = [str(c) for c in cmd] if verbose: print(">>", " ".join(cmd_str)) return subprocess.run( cmd_str, cwd=cwd, check=check, capture_output=capture_output, text=True if capture_output else None ) def run_quiet(cmd: List[Union[str, Path]], cwd: Optional[Path] = None, check: bool = False) -> subprocess.CompletedProcess: """ Execute a command silently (no output, no verbose). Args: cmd: Command and arguments to execute cwd: Working directory for the command check: If True, raise exception on non-zero exit code Returns: CompletedProcess instance """ cmd_str = [str(c) for c in cmd] return subprocess.run( cmd_str, cwd=cwd, check=check, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) # ----------------------------------------------------------------------------- # File dependency checking # ----------------------------------------------------------------------------- def is_up_to_date(source: Path, output: Path, dependency: Optional[Path] = None) -> bool: """ Check if an output file is up to date relative to its source and dependencies. Args: source: Source file path output: Output file path dependency: Optional dependency file (e.g., .d file) Returns: True if output is up to date, False if it needs rebuilding """ # Files with 'meta' in their name are never up to date (force rebuild) if "meta" in source.stem or "meta" in output.stem: return False if dependency and "meta" in dependency.stem: return False # Output doesn't exist, needs building if not output.exists(): return False # Check dependency file if provided if dependency: if dependency.exists() and dependency.stat().st_mtime > output.stat().st_mtime: return False # Check source file modification time return source.stat().st_mtime < output.stat().st_mtime def needs_rebuild(sources: List[Path], output: Path) -> bool: """ Check if any source file is newer than the output file. Args: sources: List of source file paths output: Output file path Returns: True if rebuild is needed, False otherwise """ if not output.exists(): return True output_time = output.stat().st_mtime return any(src.stat().st_mtime > output_time for src in sources if src.exists()) # ----------------------------------------------------------------------------- # Tool detection # ----------------------------------------------------------------------------- def which(tool: str) -> Optional[Path]: """ Find a tool in the system PATH. Args: tool: Tool name to find Returns: Path to the tool if found, None otherwise """ result = shutil.which(tool) return Path(result) if result else None def check_tool(tool: str, error_message: Optional[str] = None) -> bool: """ Check if a tool is available in the system PATH. Args: tool: Tool name to check error_message: Optional custom error message to print if not found Returns: True if tool is found, False otherwise """ if which(tool): return True if error_message: print(f"ERROR: {error_message}") else: print(f"ERROR: Required tool '{tool}' not found in PATH") return False def check_tools(tools: List[str], error_messages: Optional[List[str]] = None) -> bool: """ Check if multiple tools are available. Args: tools: List of tool names to check error_messages: Optional list of custom error messages (same length as tools) Returns: True if all tools are found, False otherwise """ all_found = True for i, tool in enumerate(tools): msg = error_messages[i] if error_messages and i < len(error_messages) else None if not check_tool(tool, msg): all_found = False return all_found # ----------------------------------------------------------------------------- # File operations # ----------------------------------------------------------------------------- def copy_file(src: Path, dst: Path) -> None: """ Copy a file from source to destination. Args: src: Source file path dst: Destination file path """ mkdir(dst.parent) shutil.copy2(src, dst) def get_files_recursive(directory: Path, pattern: str = "*") -> List[Path]: """ Get all files matching a pattern recursively in a directory. Args: directory: Directory to search pattern: Glob pattern to match (default: "*") Returns: List of matching file paths """ if not directory.exists(): return [] return list(directory.rglob(pattern)) def get_files(directory: Path, pattern: str = "*") -> List[Path]: """ Get all files matching a pattern in a directory (non-recursive). Args: directory: Directory to search pattern: Glob pattern to match (default: "*") Returns: List of matching file paths """ if not directory.exists(): return [] return list(directory.glob(pattern)) # ----------------------------------------------------------------------------- # String formatting # ----------------------------------------------------------------------------- def format_list(items: List[str], indent: int = 2) -> str: """ Format a list of items as an indented bulleted list. Args: items: List of strings to format indent: Number of spaces to indent Returns: Formatted string with bullet points """ indent_str = " " * indent return "\n".join(f"{indent_str}- {item}" for item in items) def print_header(text: str, char: str = "=") -> None: """ Print a header with surrounding decoration. Args: text: Header text char: Character to use for decoration """ print() print(char * len(text)) print(text) print(char * len(text)) print() def print_success(text: str) -> None: """Print a success message.""" print(f"✓ {text}") def print_error(text: str) -> None: """Print an error message to stderr.""" print(f"✗ {text}", file=sys.stderr) def print_warning(text: str) -> None: """Print a warning message.""" print(f"⚠ {text}") # ----------------------------------------------------------------------------- # Path operations # ----------------------------------------------------------------------------- def get_relative_to_project(path: Path, project_root: Optional[Path] = None) -> Path: """ Get a path relative to the project root. Args: path: Absolute or relative path project_root: Project root directory (default: current directory) Returns: Path relative to project root """ if project_root is None: project_root = Path.cwd() try: return path.relative_to(project_root) except ValueError: # Path is not relative to project root, return as-is return path def ensure_absolute(path: Path, base: Optional[Path] = None) -> Path: """ Ensure a path is absolute. Args: path: Path to make absolute base: Base directory for relative paths (default: current directory) Returns: Absolute path """ if path.is_absolute(): return path if base is None: base = Path.cwd() return (base / path).resolve() # ----------------------------------------------------------------------------- # Version comparison # ----------------------------------------------------------------------------- def compare_versions(v1: str, v2: str) -> int: """ Compare two version strings. Args: v1: First version string (e.g., "1.2.3") v2: Second version string (e.g., "1.2.4") Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2 """ def normalize(v): return [int(x) for x in v.split(".") if x.isdigit()] parts1 = normalize(v1) parts2 = normalize(v2) # Pad shorter version with zeros max_len = max(len(parts1), len(parts2)) parts1.extend([0] * (max_len - len(parts1))) parts2.extend([0] * (max_len - len(parts2))) for p1, p2 in zip(parts1, parts2): if p1 < p2: return -1 elif p1 > p2: return 1 return 0