Skip to content

Commit ae54952

Browse files
committed
feat: add dynamic PyPI mirror selection to bootstrap script
1 parent ef619c1 commit ae54952

File tree

2 files changed

+109
-33
lines changed

2 files changed

+109
-33
lines changed

bootstrap.py

Lines changed: 102 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,27 @@
22
import sys
33
import subprocess
44
import venv
5+
import time
6+
import urllib.request
57
from pathlib import Path
8+
from concurrent.futures import ThreadPoolExecutor, as_completed
69

710
# ================= 配置 =================
811
VENV_DIR_NAME = ".venv"
912
REQUIREMENTS_FILE = "requirements.txt"
10-
# 这里的包名必须是 import 时使用的名字 (例如 "tomli" 而不是 "tomli>=2.0")
11-
REQUIRED_IMPORTS = ["rich", "tomli"]
12-
REQUIRED_PACKAGES = ["rich", "tomli"] # 如果需要安装,传给 pip 的包名
13+
REQUIRED_IMPORTS = ["rich", "tomli"]
14+
REQUIRED_PACKAGES = ["rich", "tomli"]
15+
16+
# 定义待测速的 PyPI 源列表
17+
PYPI_MIRRORS = {
18+
"Official": "https://pypi.org/simple",
19+
"Tsinghua": "https://pypi.tuna.tsinghua.edu.cn/simple",
20+
"Aliyun": "https://mirrors.aliyun.com/pypi/simple",
21+
"Tencent": "https://mirrors.cloud.tencent.com/pypi/simple",
22+
}
1323
# =======================================
1424

25+
1526
def get_venv_paths(root_dir: Path):
1627
"""获取虚拟环境的路径"""
1728
venv_dir = root_dir / VENV_DIR_NAME
@@ -23,62 +34,126 @@ def get_venv_paths(root_dir: Path):
2334
pip_executable = venv_dir / "bin" / "pip"
2435
return venv_dir, python_executable, pip_executable
2536

37+
2638
def check_venv_integrity(python_executable: Path) -> bool:
27-
"""
28-
检查 venv 里的 Python 是否已经安装了必要的包。
29-
通过调用 venv python 执行尝试导入的脚本来验证。
30-
"""
39+
"""检查 venv 里的 Python 是否已经安装了必要的包。"""
3140
if not python_executable.exists():
3241
return False
33-
34-
# 构建检查脚本: "import rich; import tomli;"
42+
3543
check_script = "; ".join([f"import {pkg}" for pkg in REQUIRED_IMPORTS])
36-
3744
try:
38-
# 使用 -c 执行导入检查,stdout/stderr 扔掉,只看返回码
3945
subprocess.run(
4046
[str(python_executable), "-c", check_script],
4147
stdout=subprocess.DEVNULL,
4248
stderr=subprocess.DEVNULL,
43-
check=True
49+
check=True,
4450
)
4551
return True
4652
except subprocess.CalledProcessError:
4753
return False
4854

55+
4956
def create_venv_if_missing(venv_dir: Path):
5057
if not venv_dir.exists():
5158
print(f"[Bootstrap] Creating virtual environment at: {venv_dir}...", flush=True)
5259
venv.create(venv_dir, with_pip=True)
5360

61+
62+
def test_mirror_latency(name, url, timeout=2):
63+
"""测试单个镜像源的延迟"""
64+
start_time = time.time()
65+
try:
66+
# 使用 HEAD 请求或者极小的 GET 请求来测试连接
67+
# 标准库 urllib 发送请求
68+
req = urllib.request.Request(url, method="HEAD")
69+
with urllib.request.urlopen(req, timeout=timeout) as response:
70+
if response.status == 200:
71+
latency = (time.time() - start_time) * 1000 # ms
72+
return name, url, latency
73+
except Exception:
74+
pass
75+
return name, url, float("inf")
76+
77+
78+
def get_fastest_mirror():
79+
"""并发测试所有源,返回速度最快的源 URL"""
80+
print("[Bootstrap] Selecting the fastest PyPI mirror...", flush=True)
81+
82+
fastest_url = None
83+
min_latency = float("inf")
84+
best_name = "Unknown"
85+
86+
# 使用线程池并发测速,避免串行等待
87+
with ThreadPoolExecutor(max_workers=len(PYPI_MIRRORS)) as executor:
88+
futures = [
89+
executor.submit(test_mirror_latency, name, url)
90+
for name, url in PYPI_MIRRORS.items()
91+
]
92+
93+
for future in as_completed(futures):
94+
name, url, latency = future.result()
95+
if latency < float("inf"):
96+
# print(f" - {name}: {latency:.2f} ms") # 调试时可打开
97+
if latency < min_latency:
98+
min_latency = latency
99+
fastest_url = url
100+
best_name = name
101+
102+
if fastest_url:
103+
print(f"[Bootstrap] Selected {best_name} (Latency: {min_latency:.0f}ms)", flush=True)
104+
return fastest_url
105+
else:
106+
print("[Bootstrap] Warning: All mirrors check failed. Fallback to Official.", flush=True)
107+
return PYPI_MIRRORS["Official"]
108+
109+
54110
def install_dependencies(root_dir: Path, pip_executable: Path):
55111
req_path = root_dir / REQUIREMENTS_FILE
56-
base_cmd = [str(pip_executable), "install", "--disable-pip-version-check", "-i", "https://pypi.tuna.tsinghua.edu.cn/simple"]
57112

58-
print(f"[Bootstrap] Installing dependencies...", flush=True)
113+
# === 获取最快源 ===
114+
mirror_url = get_fastest_mirror()
115+
# ================
116+
117+
base_cmd = [
118+
str(pip_executable),
119+
"install",
120+
"--disable-pip-version-check",
121+
"-i",
122+
mirror_url, # 使用动态获取的源
123+
]
124+
125+
# 如果选中的不是官方源,通常需要信任该 host,避免 pip 报 SSL 警告
126+
if "pypi.org" not in mirror_url:
127+
from urllib.parse import urlparse
128+
hostname = urlparse(mirror_url).hostname
129+
base_cmd.extend(["--trusted-host", hostname])
130+
131+
print("[Bootstrap] Installing dependencies...", flush=True)
59132
try:
60133
if req_path.exists():
61134
subprocess.run(base_cmd + ["-r", str(req_path)], check=True)
62135
else:
63136
subprocess.run(base_cmd + REQUIRED_PACKAGES, check=True)
64137
except subprocess.CalledProcessError:
65-
print(f"[Bootstrap] Error: Failed to install dependencies.", file=sys.stderr)
138+
print("[Bootstrap] Error: Failed to install dependencies.", file=sys.stderr)
66139
sys.exit(1)
67140

141+
68142
def restart_in_venv(python_executable: Path):
69143
"""重启当前脚本到 venv 环境"""
70144
env = os.environ.copy()
71145
env["GRADER_BOOTSTRAPPED"] = "1"
72146
args = [str(python_executable)] + sys.argv
73-
147+
74148
if sys.platform == "win32":
75149
subprocess.run(args, env=env, check=True)
76150
sys.exit(0)
77151
else:
78152
os.execv(str(python_executable), args)
79153

154+
80155
def initialize():
81-
# 1. Fast Path: 如果当前环境(无论是否venv)已经能用,直接通过
156+
# 1. Fast Path
82157
try:
83158
for pkg in REQUIRED_IMPORTS:
84159
__import__(pkg)
@@ -90,21 +165,23 @@ def initialize():
90165
venv_dir, venv_python, venv_pip = get_venv_paths(root_dir)
91166
is_in_venv = os.environ.get("GRADER_BOOTSTRAPPED") == "1"
92167

93-
# 2. 如果已经在 venv 里但还是缺包,说明环境坏了,强制修复
168+
# 2. 修复损坏环境
94169
if is_in_venv:
95170
print("[Bootstrap] In venv but dependencies missing. Repairing...", flush=True)
96171
install_dependencies(root_dir, venv_pip)
97172
return
98173

99-
# 3. 如果在系统环境 (用户没激活 venv 直接跑脚本)
100-
# 逻辑:先检查 venv 是否存在且完好,如果是,直接重启进去,不要跑 install
101-
174+
# 3. 创建与首次安装
102175
create_venv_if_missing(venv_dir)
103176

104-
# === 关键优化点 ===
105-
# 只有当 venv 里的 python 无法导入指定包时,才运行 pip install
106177
if not check_venv_integrity(venv_python):
107178
install_dependencies(root_dir, venv_pip)
108-
# =================
109-
179+
110180
restart_in_venv(venv_python)
181+
182+
# 示例用法,实际使用时这里可能是 main()
183+
if __name__ == "__main__":
184+
initialize()
185+
# 下面写你的正常业务逻辑
186+
import rich
187+
print("[Success] Environment is ready!")

grader.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import json
33
import os
44
import re
5-
import shutil
65
import subprocess
76
import sys
87
import time
@@ -19,7 +18,7 @@
1918
# -----------------------------------------
2019

2120
import tomli # noqa: E402
22-
from rich.console import Console # noqa: E402
21+
from rich.console import Console, RenderableType # noqa: E402
2322
from rich.panel import Panel # noqa: E402
2423
from rich.progress import Progress, SpinnerColumn, Task, TextColumn # noqa: E402
2524
from rich.table import Table # noqa: E402
@@ -196,7 +195,7 @@ def _resolve_path(self, path: str, test_dir: Path) -> str:
196195
class StatusSpinnerColumn(SpinnerColumn):
197196
"""Spinner that swaps to a custom status icon when provided."""
198197

199-
def render(self, task: Task) -> Text:
198+
def render(self, task: Task) -> RenderableType:
200199
icon = task.fields.get("status_icon")
201200
if icon:
202201
return Text.from_markup(icon)
@@ -605,7 +604,7 @@ def _execute_single_step(
605604

606605
return self._create_success_result(test, step, score, start_time)
607606

608-
def _resolve_relative_path(self, path: str, cwd: Path = os.getcwd()) -> str:
607+
def _resolve_relative_path(self, path: str, cwd: Path = Path.cwd()) -> str:
609608
result = path
610609
if isinstance(path, Path):
611610
result = str(path.resolve())
@@ -623,7 +622,7 @@ def _resolve_relative_path(self, path: str, cwd: Path = os.getcwd()) -> str:
623622

624623
return result
625624

626-
def _resolve_path(self, path: str, test_dir: Path, cwd: Path = os.getcwd()) -> str:
625+
def _resolve_path(self, path: str, test_dir: Path, cwd: Path = Path.cwd()) -> str:
627626
build_dir = test_dir / "build"
628627
build_dir.mkdir(exist_ok=True)
629628

@@ -1025,7 +1024,7 @@ def _generate_launch_config(
10251024
else:
10261025
raise ValueError(f"Unsupported debug type: {debug_type}")
10271026

1028-
def _generate_tasks_config(self, test_case: TestCase) -> Dict[str, Any]:
1027+
def _generate_tasks_config(self, test_case: TestCase) -> List[Dict[str, Any]]:
10291028
"""Generate tasks configuration for building the test case"""
10301029
return [
10311030
{
@@ -1092,7 +1091,7 @@ def _write_or_merge_json(
10921091
with open(file_path, "w") as f:
10931092
json.dump(config_to_write, f, indent=4)
10941093

1095-
def _resolve_relative_path(self, path: str, cwd: Path = os.getcwd()) -> str:
1094+
def _resolve_relative_path(self, path: str, cwd: Path = Path.cwd()) -> str:
10961095
result = path
10971096
if isinstance(path, Path):
10981097
result = str(path.resolve())
@@ -1110,7 +1109,7 @@ def _resolve_relative_path(self, path: str, cwd: Path = os.getcwd()) -> str:
11101109

11111110
return result
11121111

1113-
def _resolve_path(self, path: str, test_dir: Path, cwd: Path = os.getcwd()) -> str:
1112+
def _resolve_path(self, path: str, test_dir: Path, cwd: Path = Path.cwd()) -> str:
11141113
build_dir = test_dir / "build"
11151114
build_dir.mkdir(exist_ok=True)
11161115

0 commit comments

Comments
 (0)