Created utils

This commit is contained in:
Kyler Olsen 2025-12-19 14:00:38 -07:00
parent 1f0fccc866
commit 2efdd1d2e2
1 changed files with 394 additions and 0 deletions

View File

@ -0,0 +1,394 @@
#!/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