257 lines
6.6 KiB
Python
257 lines
6.6 KiB
Python
"""
|
|
Utility functions for the build system.
|
|
|
|
This module provides common utilities used across the build system:
|
|
- File and directory operations
|
|
- Git integration
|
|
- Dependency tracking
|
|
- Process execution helpers
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import shutil
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
|
|
def mkdir(path: Path):
|
|
"""
|
|
Create a directory if it doesn't exist.
|
|
|
|
Args:
|
|
path: Directory path to create
|
|
"""
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def rm_tree(path: Path):
|
|
"""
|
|
Remove a directory tree if it exists.
|
|
|
|
Args:
|
|
path: Directory path to remove
|
|
"""
|
|
if path.exists():
|
|
shutil.rmtree(path, ignore_errors=True)
|
|
|
|
|
|
def run_command(cmd: list, cwd: Optional[Path] = None, capture_output: bool = False):
|
|
"""
|
|
Run a command and optionally capture its output.
|
|
|
|
Args:
|
|
cmd: Command and arguments as list
|
|
cwd: Working directory (default: current directory)
|
|
capture_output: If True, return output; if False, print to console
|
|
|
|
Returns:
|
|
Command output as string if capture_output=True, else None
|
|
|
|
Raises:
|
|
subprocess.CalledProcessError: If command fails
|
|
"""
|
|
if not capture_output:
|
|
print(">>", " ".join(str(c) for c in cmd))
|
|
subprocess.check_call(cmd, cwd=cwd)
|
|
return None
|
|
else:
|
|
result = subprocess.check_output(
|
|
cmd,
|
|
cwd=cwd,
|
|
stderr=subprocess.DEVNULL,
|
|
text=True
|
|
)
|
|
return result.strip()
|
|
|
|
|
|
def git_commit_hash() -> str:
|
|
"""
|
|
Get the current git commit hash and date.
|
|
|
|
Returns:
|
|
String like "a1b2c3d 2024-12-18 10:30:45 -0700" or "unknown" if not a git repo
|
|
"""
|
|
try:
|
|
# Get short hash with dirty flag
|
|
hash_result = subprocess.check_output(
|
|
["git", "describe", "--always", "--dirty", "--abbrev=7"],
|
|
stderr=subprocess.DEVNULL,
|
|
text=True
|
|
).strip()
|
|
|
|
# Get commit date
|
|
date_result = subprocess.check_output(
|
|
["git", "show", "-s", "--format=%ci"],
|
|
stderr=subprocess.DEVNULL,
|
|
text=True
|
|
).strip()
|
|
|
|
return f"{hash_result} {date_result}"
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
return "unknown"
|
|
|
|
|
|
def is_up_to_date(source: Path, output: Path, deps: Optional[Path] = None) -> bool:
|
|
"""
|
|
Check if an output file is up to date relative to its source.
|
|
|
|
Args:
|
|
source: Source file path
|
|
output: Output file path
|
|
deps: Optional dependency file path
|
|
|
|
Returns:
|
|
True if output is newer than source (and deps), False otherwise
|
|
"""
|
|
# Special case: always rebuild files with "meta" in name
|
|
if "meta" in source.stem or "meta" in output.stem:
|
|
return False
|
|
|
|
# If output doesn't exist, not up to date
|
|
if not output.exists():
|
|
return False
|
|
|
|
# Check if dependency file is newer than output
|
|
if deps and deps.exists():
|
|
if deps.stat().st_mtime > output.stat().st_mtime:
|
|
return False
|
|
|
|
# Check if source is newer than output
|
|
return source.stat().st_mtime < output.stat().st_mtime
|
|
|
|
|
|
def find_sources(directory: Path, pattern: str = "*.c", exclude: Optional[list] = None) -> list:
|
|
"""
|
|
Find source files in a directory with optional exclusions.
|
|
|
|
Args:
|
|
directory: Directory to search
|
|
pattern: Glob pattern for files (default: "*.c")
|
|
exclude: List of filenames or patterns to exclude
|
|
|
|
Returns:
|
|
List of Path objects for matching files
|
|
"""
|
|
exclude = exclude or []
|
|
sources = list(directory.glob(pattern))
|
|
|
|
# Filter out excluded files
|
|
filtered = []
|
|
for src in sources:
|
|
# Check exact filename matches
|
|
if src.name in exclude:
|
|
continue
|
|
|
|
# Check pattern matches in stem
|
|
if any(excl.lower() in src.stem.lower() for excl in exclude):
|
|
continue
|
|
|
|
filtered.append(src)
|
|
|
|
return filtered
|
|
|
|
|
|
def detect_python() -> str:
|
|
"""
|
|
Detect the appropriate Python executable.
|
|
|
|
Returns:
|
|
'python3' on Unix-like systems, 'python' on Windows
|
|
"""
|
|
if os.name == "nt":
|
|
return "python"
|
|
return "python3"
|
|
|
|
|
|
def file_hash(path: Path) -> Optional[str]:
|
|
"""
|
|
Calculate SHA256 hash of a file.
|
|
|
|
Args:
|
|
path: File path
|
|
|
|
Returns:
|
|
Hex string of file hash, or None if file doesn't exist
|
|
"""
|
|
import hashlib
|
|
|
|
if not path.exists():
|
|
return None
|
|
|
|
sha256 = hashlib.sha256()
|
|
with open(path, 'rb') as f:
|
|
for chunk in iter(lambda: f.read(4096), b''):
|
|
sha256.update(chunk)
|
|
|
|
return sha256.hexdigest()
|
|
|
|
|
|
def needs_rebuild(source: Path, output: Path, deps_file: Optional[Path] = None) -> bool:
|
|
"""
|
|
Advanced dependency checking including header dependencies.
|
|
|
|
Args:
|
|
source: Source file
|
|
output: Output file
|
|
deps_file: Dependency file (.d) generated by compiler
|
|
|
|
Returns:
|
|
True if rebuild is needed
|
|
"""
|
|
# Basic up-to-date check
|
|
if not is_up_to_date(source, output, deps_file):
|
|
return True
|
|
|
|
# If we have a dependency file, check all dependencies
|
|
if deps_file and deps_file.exists():
|
|
try:
|
|
with open(deps_file, 'r') as f:
|
|
content = f.read()
|
|
|
|
# Parse Make-style dependencies
|
|
# Format: target: dep1 dep2 dep3
|
|
if ':' in content:
|
|
_, deps_str = content.split(':', 1)
|
|
deps = deps_str.strip().split()
|
|
|
|
output_mtime = output.stat().st_mtime
|
|
|
|
# Check if any dependency is newer than output
|
|
for dep in deps:
|
|
dep_path = Path(dep.strip('\\').strip())
|
|
if dep_path.exists():
|
|
if dep_path.stat().st_mtime > output_mtime:
|
|
return True
|
|
except Exception:
|
|
# If we can't parse deps, rebuild to be safe
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def copy_file(src: Path, dst: Path):
|
|
"""
|
|
Copy a file, creating destination directory if needed.
|
|
|
|
Args:
|
|
src: Source file
|
|
dst: Destination file
|
|
"""
|
|
mkdir(dst.parent)
|
|
shutil.copy2(src, dst)
|
|
|
|
|
|
def which(executable: str) -> Optional[Path]:
|
|
"""
|
|
Find an executable in PATH.
|
|
|
|
Args:
|
|
executable: Name of executable
|
|
|
|
Returns:
|
|
Path to executable if found, None otherwise
|
|
"""
|
|
result = shutil.which(executable)
|
|
return Path(result) if result else None
|