Skip to content

Commit 410458c

Browse files
committed
add helper script to download the needed files to mirror for UV_PYTHON_INSTALL_MIRROR
1 parent 99a8746 commit 410458c

1 file changed

Lines changed: 241 additions & 0 deletions

File tree

crates/uv-python/create-mirror.py

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import argparse
2+
import asyncio
3+
import hashlib
4+
import json
5+
import logging
6+
from pathlib import Path
7+
from typing import Dict, List, Optional, Set, Tuple
8+
from urllib.parse import unquote
9+
10+
import httpx
11+
from git import GitCommandError, Repo # GitPython
12+
from tqdm import tqdm
13+
14+
# Constants
15+
SELF_DIR = Path(__file__).parent
16+
REPO_ROOT = SELF_DIR.parent.parent
17+
VERSIONS_FILE = SELF_DIR / "download-metadata.json"
18+
DST_DIR = SELF_DIR / "mirror"
19+
PREFIXES = [
20+
"https://github.com/indygreg/python-build-standalone/releases/download/",
21+
"https://downloads.python.org/pypy/",
22+
]
23+
24+
# Logger setup
25+
logging.basicConfig(
26+
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
27+
)
28+
logger = logging.getLogger(__name__)
29+
30+
# Silence httpx logging
31+
logging.getLogger("httpx").setLevel(logging.WARNING)
32+
logging.getLogger("httpcore").setLevel(logging.WARNING)
33+
34+
35+
def sanitize_url(url: str) -> Path:
36+
"""Remove the prefix from the URL, decode it, and convert it to a relative path."""
37+
for prefix in PREFIXES:
38+
if url.startswith(prefix):
39+
return Path(unquote(url[len(prefix) :])) # Decode the URL path
40+
return Path(unquote(url)) # Fallback to full decoded path if no prefix matched
41+
42+
43+
def sha256_checksum(file_path: Path) -> str:
44+
"""Calculate the SHA-256 checksum of a file."""
45+
hasher = hashlib.sha256()
46+
with open(file_path, "rb") as f:
47+
for chunk in iter(lambda: f.read(8192), b""):
48+
hasher.update(chunk)
49+
return hasher.hexdigest()
50+
51+
52+
def collect_metadata_from_git_history() -> List[Dict]:
53+
"""Collect all metadata entries from the history of the VERSIONS_FILE."""
54+
metadata = []
55+
try:
56+
repo = Repo(REPO_ROOT, search_parent_directories=True)
57+
58+
for commit in repo.iter_commits(paths=VERSIONS_FILE):
59+
try:
60+
# Ensure the file exists in the commit tree
61+
blob = commit.tree / str(VERSIONS_FILE.relative_to(REPO_ROOT))
62+
content = blob.data_stream.read().decode()
63+
data = json.loads(content)
64+
metadata.extend(data.values())
65+
except KeyError:
66+
logger.warning(
67+
f"File {VERSIONS_FILE} not found in commit {commit.hexsha}. Skipping."
68+
)
69+
except json.JSONDecodeError as e:
70+
logger.error(f"Error decoding JSON in commit {commit.hexsha}: {e}")
71+
72+
except GitCommandError as e:
73+
logger.error(f"Git command error: {e}")
74+
except Exception as e:
75+
logger.exception(f"Unexpected error while collecting metadata: {e}")
76+
77+
return metadata
78+
79+
80+
def filter_metadata(
81+
metadata: List[Dict], name: Optional[str], arch: Optional[str], os: Optional[str]
82+
) -> List[Dict]:
83+
"""Filter the metadata based on name, architecture, and OS, ensuring unique URLs."""
84+
filtered = [
85+
entry
86+
for entry in metadata
87+
if (not name or entry["name"] == name)
88+
and (not arch or entry["arch"] == arch)
89+
and (not os or entry["os"] == os)
90+
]
91+
# Use a set to ensure unique URLs
92+
unique_urls = set()
93+
unique_filtered = []
94+
for entry in filtered:
95+
if entry["url"] not in unique_urls:
96+
unique_urls.add(entry["url"])
97+
unique_filtered.append(entry)
98+
return unique_filtered
99+
100+
101+
async def download_file(
102+
client: httpx.AsyncClient,
103+
url: str,
104+
dest: Path,
105+
expected_sha256: Optional[str],
106+
progress_bar,
107+
errors,
108+
):
109+
"""Download a file and verify its SHA-256 checksum if provided."""
110+
if dest.exists() and expected_sha256 and sha256_checksum(dest) == expected_sha256:
111+
logger.debug(
112+
f"File {dest} already exists and SHA-256 matches. Skipping download."
113+
)
114+
progress_bar.update(1)
115+
return True # Success, even though skipped
116+
elif dest.exists() and expected_sha256 is None:
117+
logger.debug(
118+
f"File {dest} already exists no SHA-256 provided. Skipping download."
119+
)
120+
progress_bar.update(1)
121+
return True # Success, even though skipped
122+
123+
if not any(url.startswith(prefix) for prefix in PREFIXES):
124+
error_msg = f"No valid prefix found for {url}. Skipping."
125+
logger.warning(error_msg)
126+
errors.append((url, error_msg))
127+
progress_bar.update(1)
128+
return False
129+
130+
dest.parent.mkdir(parents=True, exist_ok=True)
131+
logger.debug(f"Downloading {url} to {dest}")
132+
133+
try:
134+
async with client.stream("GET", url) as response:
135+
response.raise_for_status()
136+
with open(dest, "wb") as f:
137+
async for chunk in response.aiter_bytes():
138+
f.write(chunk)
139+
140+
if expected_sha256 and sha256_checksum(dest) != expected_sha256:
141+
error_msg = f"SHA-256 mismatch for {dest}. Deleting corrupted file."
142+
logger.error(error_msg)
143+
dest.unlink()
144+
errors.append((url, "Checksum mismatch"))
145+
progress_bar.update(1)
146+
return False
147+
148+
except Exception as e:
149+
error_msg = f"Failed to download {url}: {str(e)}"
150+
logger.error(error_msg)
151+
errors.append((url, str(e)))
152+
progress_bar.update(1)
153+
return False
154+
155+
progress_bar.update(1)
156+
return True
157+
158+
159+
async def download_files(urls: Set[Tuple[str, Optional[str]]], max_concurrent: int):
160+
"""Download files with a limit on concurrent downloads using httpx."""
161+
async with httpx.AsyncClient(follow_redirects=True) as client:
162+
progress_bar = tqdm(total=len(urls), desc="Downloading", unit="file")
163+
sem = asyncio.Semaphore(max_concurrent)
164+
errors: List[Tuple[str, str]] = [] # To collect errors
165+
success_count = 0 # Track number of successful downloads
166+
167+
async def sem_download(url, sha256):
168+
nonlocal success_count
169+
async with sem:
170+
success = await download_file(
171+
client,
172+
url,
173+
DST_DIR / sanitize_url(url),
174+
sha256,
175+
progress_bar,
176+
errors,
177+
)
178+
if success:
179+
success_count += 1
180+
181+
tasks = [sem_download(url, sha256) for url, sha256 in urls]
182+
await asyncio.gather(*tasks)
183+
progress_bar.close()
184+
185+
return success_count, errors
186+
187+
188+
def parse_arguments():
189+
"""Parse command-line arguments using argparse."""
190+
parser = argparse.ArgumentParser(description="Download and mirror Python builds.")
191+
parser.add_argument("--name", help="Filter by name (e.g., 'cpython').")
192+
parser.add_argument("--arch", help="Filter by architecture (e.g., 'aarch64').")
193+
parser.add_argument("--os", help="Filter by operating system (e.g., 'darwin').")
194+
parser.add_argument(
195+
"--max-concurrent",
196+
type=int,
197+
default=20,
198+
help="Maximum number of simultaneous downloads.",
199+
)
200+
parser.add_argument(
201+
"--from-all-history",
202+
action="store_true",
203+
help="Collect URLs from the entire git history.",
204+
)
205+
return parser.parse_args()
206+
207+
208+
def main():
209+
"""Main function to run the CLI."""
210+
args = parse_arguments()
211+
212+
if args.from_all_history:
213+
metadata = collect_metadata_from_git_history()
214+
else:
215+
with open(VERSIONS_FILE) as f:
216+
metadata = list(json.load(f).values())
217+
218+
filtered_metadata = filter_metadata(metadata, args.name, args.arch, args.os)
219+
urls = {(entry["url"], entry["sha256"]) for entry in filtered_metadata}
220+
221+
if not urls:
222+
logger.error("No URLs found.")
223+
return
224+
225+
logger.info(f"Starting download of {len(urls)} files.")
226+
try:
227+
success_count, errors = asyncio.run(download_files(urls, args.max_concurrent))
228+
print(f"Successfully downloaded: {success_count}")
229+
if errors:
230+
print("Failed downloads:")
231+
for url, error in errors:
232+
print(f"- {url}: {error}")
233+
print(
234+
f"now you can run UV_PYTHON_INSTALL_MIRROR='file://{DST_DIR.absolute()}' uv python install"
235+
)
236+
except Exception as e:
237+
logger.error(f"Error during download: {e}")
238+
239+
240+
if __name__ == "__main__":
241+
main()

0 commit comments

Comments
 (0)