Files
zig-fetch-py/zig_fetch_py/downloader.py
crosstyan cc0d988012 Add recursive dependency downloading and directory scanning
- Implement recursive downloading of nested dependencies from ZON files
- Enhance CLI to support downloading from directories containing `build.zig.zon` files
- Update README with new usage instructions and command options
- Refactor dependency processing to handle both single files and directories
2025-03-19 11:22:46 +08:00

333 lines
10 KiB
Python

"""
Dependency downloader for Zig packages.
This module handles downloading and extracting dependencies specified in ZON files.
"""
import shutil
import click
import sys
import tarfile
import tempfile
from pathlib import Path
from typing import Dict, Any, Optional, Set, List
import httpx
from loguru import logger
from zig_fetch_py.parser import parse_zon_file
def get_cache_dir() -> Path:
"""
Get the Zig cache directory for packages.
Returns:
Path to the Zig cache directory (~/.cache/zig/p)
"""
cache_dir = Path.home() / ".cache" / "zig" / "p"
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir
def download_file(url: str, target_path: Path) -> None:
"""
Download a file from a URL to a target path.
Args:
url: URL to download from
target_path: Path to save the downloaded file
"""
logger.info(f"Downloading {url} to {target_path}")
# Create client with environment proxies
with httpx.Client(follow_redirects=True) as client:
with client.stream("GET", url) as response:
response.raise_for_status()
# Create parent directories if they don't exist
target_path.parent.mkdir(parents=True, exist_ok=True)
# Write the file
with open(target_path, "wb") as f:
for chunk in response.iter_bytes():
f.write(chunk)
def extract_tarball(tarball_path: Path, extract_dir: Path) -> Path:
"""
Extract a tarball to a directory.
Args:
tarball_path: Path to the tarball
extract_dir: Directory to extract to
Returns:
Path to the extracted directory
"""
logger.info(f"Extracting {tarball_path} to {extract_dir}")
# Create extraction directory if it doesn't exist
extract_dir.mkdir(parents=True, exist_ok=True)
with tarfile.open(tarball_path, "r:*") as tar:
# Get the common prefix of all files in the tarball
members = tar.getmembers()
common_prefix = Path(Path(members[0].name).parts[0]) if members else None
# Extract all files
tar.extractall(path=extract_dir)
# Return the path to the extracted directory
return extract_dir / common_prefix if common_prefix else extract_dir
def process_dependency(
name: str, dep_info: Dict[str, Any], cache_dir: Path
) -> Optional[Path]:
"""
Process a single dependency from a ZON file.
Args:
name: Name of the dependency
dep_info: Dependency information from the ZON file
cache_dir: Cache directory to store the dependency
Returns:
Path to the extracted dependency directory, or None if the dependency is already cached
"""
url = dep_info.get("url")
hash_value = dep_info.get("hash")
if not url or not hash_value:
logger.warning(f"Dependency {name} is missing url or hash, skipping")
return None
# Check if the dependency is already cached
target_dir = cache_dir / hash_value
if target_dir.exists():
logger.info(
f"Dependency {name} ({hash_value}) is already cached at {target_dir}"
)
return target_dir
# Create a temporary directory for downloading and extracting
with tempfile.TemporaryDirectory() as temp_dir:
temp_dir_path = Path(temp_dir)
# Download the tarball
tarball_path = temp_dir_path / f"{name}.tar.gz"
download_file(url, tarball_path)
# Extract the tarball to a temporary directory
extract_path = extract_tarball(tarball_path, temp_dir_path / "extract")
# Move the extracted directory to the cache directory with the hash as the name
if extract_path and extract_path.exists():
if not target_dir.parent.exists():
target_dir.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(extract_path), str(target_dir))
logger.info(f"Dependency {name} ({hash_value}) cached at {target_dir}")
return target_dir
else:
logger.error(f"Failed to extract {name} from {tarball_path}")
return None
def find_build_zig_zon_files(directory: Path) -> List[Path]:
"""
Find all build.zig.zon files in a directory and its subdirectories.
Args:
directory: Directory to search in
Returns:
List of paths to build.zig.zon files
"""
logger.debug(f"Searching for build.zig.zon files in {directory}")
zon_files = []
for zon_file in directory.glob("**/build.zig.zon"):
logger.debug(f"Found build.zig.zon file: {zon_file}")
zon_files.append(zon_file)
return zon_files
def process_dependencies(
zon_file_path: str, recursive: bool = False
) -> Dict[str, Path]:
"""
Process all dependencies from a ZON file.
Args:
zon_file_path: Path to the ZON file or directory
recursive: Whether to process dependencies recursively
Returns:
Dictionary mapping dependency names to their extracted paths
"""
# Convert to Path object
zon_file = Path(zon_file_path)
# If the path is a directory, find all build.zig.zon files
if zon_file.is_dir():
logger.info(f"Processing directory: {zon_file}")
all_zon_files = find_build_zig_zon_files(zon_file)
if not all_zon_files:
logger.warning(f"No build.zig.zon files found in {zon_file}")
return {}
# Process all found ZON files
result = {}
for zon_path in all_zon_files:
logger.info(f"Processing {zon_path}")
deps = process_dependencies_from_file(str(zon_path), recursive)
result.update(deps)
return result
# Otherwise, process the single ZON file
return process_dependencies_from_file(zon_file_path, recursive)
def process_dependencies_from_file(
zon_file_path: str, recursive: bool = False
) -> Dict[str, Path]:
"""
Process all dependencies from a single ZON file.
Args:
zon_file_path: Path to the ZON file
recursive: Whether to process dependencies recursively
Returns:
Dictionary mapping dependency names to their extracted paths
"""
logger.info(f"Processing dependencies from file: {zon_file_path}")
# Parse the ZON file
try:
zon_data = parse_zon_file(zon_file_path)
except Exception as e:
logger.error(f"Error parsing {zon_file_path}: {e}")
return {}
# Get the dependencies section
dependencies = zon_data.get("dependencies", {})
if not dependencies:
logger.warning(f"No dependencies found in {zon_file_path}")
return {}
# Get the cache directory
cache_dir = get_cache_dir()
# Process each dependency
result = {}
processed_paths = set()
for name, dep_info in dependencies.items():
path = process_dependency(name, dep_info, cache_dir)
if path:
result[name] = path
processed_paths.add(path)
# Recursively process dependencies if requested
if recursive and path.exists():
process_nested_dependencies(path, result, processed_paths)
return result
def process_nested_dependencies(
dep_path: Path, result: Dict[str, Path], processed_paths: Set[Path]
) -> None:
"""
Process nested dependencies from a dependency directory.
Args:
dep_path: Path to the dependency directory
result: Dictionary to update with new dependencies
processed_paths: Set of paths that have already been processed
"""
# Find all build.zig.zon files in the dependency directory
zon_files = find_build_zig_zon_files(dep_path)
if not zon_files:
logger.debug(f"No nested build.zig.zon files found in {dep_path}")
return
for zon_file in zon_files:
logger.info(f"Processing nested dependency file: {zon_file}")
# Parse the ZON file
try:
zon_data = parse_zon_file(str(zon_file))
except Exception as e:
logger.error(f"Error parsing {zon_file}: {e}")
continue
# Get the dependencies section
dependencies = zon_data.get("dependencies", {})
if not dependencies:
logger.debug(f"No dependencies found in {zon_file}")
continue
# Get the cache directory
cache_dir = get_cache_dir()
# Process each dependency
for name, dep_info in dependencies.items():
path = process_dependency(name, dep_info, cache_dir)
if path and path not in processed_paths:
result[name] = path
processed_paths.add(path)
# Recursively process this dependency's dependencies
process_nested_dependencies(path, result, processed_paths)
def main(zon_file_path: str, recursive: bool = False) -> None:
"""
Main entry point for the dependency downloader.
Args:
zon_file_path: Path to the ZON file or directory
recursive: Whether to process dependencies recursively
"""
logger.info(f"Processing dependencies from {zon_file_path}")
dependencies = process_dependencies(zon_file_path, recursive=recursive)
if dependencies:
logger.info(f"Successfully processed {len(dependencies)} dependencies:")
for name, path in dependencies.items():
logger.info(f" - {name}: {path}")
else:
logger.warning("No dependencies were processed")
@click.command()
@click.argument("zon_file", type=click.Path(exists=True, readable=True))
@click.option(
"--recursive",
"-r",
is_flag=True,
help="Recursively process dependencies from downloaded artifacts",
)
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging")
def cli(zon_file, recursive, verbose):
"""Download dependencies from a ZON file."""
# Set up logging
log_level = "DEBUG" if verbose else "INFO"
logger.remove()
logger.add(sys.stderr, level=log_level)
# Run the main function
main(zon_file, recursive=recursive)
if __name__ == "__main__":
cli() # pylint: disable=no-value-for-parameter