Sklearn-源码解析-书-v1-0-三十九-
Sklearn 源码解析(书)v1.0(三十九)
作为"随车工具包装配工",这段函数生成的 _distributor_init.py 是整个 wheel 中最精妙的"挡风玻璃说明书"——它必须在其他任何 Cython 扩展模块之前执行,因为 OpenMP 符号是这些扩展模块的运行期依赖。if os.name == "nt" 守护让这份说明书在 Linux/macOS 上成为"空操作",但在 Windows 上则通过 op.abspath 把 DLL 路径转为绝对路径——绝对路径的选用极其关键,因为 Windows 的 DLL 搜索顺序对当前工作目录敏感(若用户在 D:\ 下运行 Python 而 wheel 装在 C:\Python\Lib\site-packages\ 下,相对路径就找不到 DLL),这种"无论车开到哪里都贴上明确地址"的工程化设计正是 Windows wheel 能真正开箱即用的根本。
源码路径:build_tools/github/vendor.py - main()(52-85行)
def main(wheel_dirname):
"""Embed vcomp140.dll and msvcp140.dll."""
# ① 校验源 DLL 存在性(缺失则立即失败,避免打包残缺 wheel)
if not op.exists(VCOMP140_SRC_PATH):
raise ValueError(f"Could not find {VCOMP140_SRC_PATH}.")
if not op.exists(MSVCP140_SRC_PATH):
raise ValueError(f"Could not find {MSVCP140_SRC_PATH}.")
# ② 校验 wheel 解压目录存在
if not op.isdir(wheel_dirname):
raise RuntimeError(f"Could not find {wheel_dirname} file.")
# ③ 拼接 wheel 内部目标路径
vcomp140_dll_filename = op.basename(VCOMP140_SRC_PATH)
msvcp140_dll_filename = op.basename(MSVCP140_SRC_PATH)
target_folder = op.join(wheel_dirname, TARGET_FOLDER)
distributor_init = op.join(wheel_dirname, DISTRIBUTOR_INIT)
# ④ 创建 sklearn/.libs 子目录(不存在才创建,幂等)
if not op.exists(target_folder):
os.mkdir(target_folder)
# ⑤ 用 shutil.copy2 保留原文件的元数据(时间戳、权限),对可复现构建至关重要
print(f"Copying {VCOMP140_SRC_PATH} to {target_folder}.")
shutil.copy2(VCOMP140_SRC_PATH, target_folder)
print(f"Copying {MSVCP140_SRC_PATH} to {target_folder}.")
shutil.copy2(MSVCP140_SRC_PATH, target_folder)
# ⑥ 生成预加载脚本(覆盖已有文件,确保最新)
print("Generating the '_distributor_init.py' file.")
make_distributor_init_64_bits(
distributor_init,
vcomp140_dll_filename,
msvcp140_dll_filename,
)
main 函数像装配工的车间流水线:先"三检"(源 DLL、wheel 目录、目标路径),再"备料"(建子目录、复制 DLL、生成说明),最后"贴标签"(生成 _distributor_init.py)。其中 shutil.copy2 而非 shutil.copy 的选择体现了"可复现构建"思想——copy2 保留原文件的元数据(修改时间、权限位),这样多次构建的 wheel 二进制哈希能保持一致,便于缓存与溯源。
源码路径:build_tools/github/vendor.py - __main__(87-90行)
if __name__ == "__main__":
# ① 接收 wheel_dirname 作为命令行参数(sys.argv[0] 是脚本自身,sys.argv[1] 是 wheel 目录)
_, wheel_file = sys.argv
main(wheel_file)
脚本入口的 _, wheel_file = sys.argv 解包看起来简单,却遵循了 Python 命令行工具的经典约定——把脚本名丢弃,只保留真正的业务参数。这种"简洁入口"设计便于在 CI 中通过 python vendor.py <wheel_dirname> 一行命令调用,无需任何 argparse 的繁琐配置。
93.12 wheel 许可证合规检查 —— 给法律合规加一把"自动化锁"
检查目标:验证安装后的 scikit_learn-*.dist-info/licenses/COPYING 文件存在且内容完整;确认许可证文本包含版权声明 Copyright (c);确认包含针对当前平台(platform.system())的打包软件许可证声明。实现要点:利用 site.getsitepackages() 遍历 site-packages 目录定位 .dist-info,使用 pathlib.Path.glob 匹配版本无关的 dist-info 目录名。
源码路径:build_tools/wheels/check_license.py - __main__(1-28行)
"""Checks the bundled license is installed with the wheel."""
import platform
import site
from itertools import chain
from pathlib import Path
# 第 93 章 —— ① 获取所有 site-packages 目录(venv 与系统 site-packages 都会被列出)
site_packages = site.getsitepackages()
site_packages_path = (Path(p) for p in site_packages)
try:
# ② 在每个 site-packages 中递归查找 scikit-learn 的 dist-info
# chain + 生成器表达式实现惰性跨目录搜索,避免一次性构造大列表
distinfo_path = next(
chain(
s
for site_package in site_packages_path
for s in site_package.glob("scikit_learn-*.dist-info")
)
)
except StopIteration as e:
# ③ 找不到 dist-info 说明 wheel 安装失败或路径异常,立即终止
raise RuntimeError("Unable to find scikit-learn's dist-info") from e
# 第 93 章 —— ④ 读取许可证文件
license_text = (distinfo_path / "licenses" / "COPYING").read_text()
# 第 93 章 —— ⑤ 断言:必须包含 BSD 版权声明
assert "Copyright (c)" in license_text
# 第 93 章 —— ⑥ 断言:必须包含针对当前平台的第三方组件许可证声明
assert (
"This binary distribution of scikit-learn also bundles the following software"
in license_text
), f"Unable to find bundled license for {platform.system()}"
作为"合规法务官",这段脚本虽然只有二十几行代码,却把"逐项核对"做到了极致:第一道断言检查"Copyright (c)"——这是 BSD 许可证的法定要件,缺了它 scikit-learn 将无法合法声明版权;第二道断言检查针对当前操作系统的第三方组件声明(Windows / Linux / macOS 各有不同的 OpenMP 运行时库捆绑),这种"平台相关的合规要求"必须用 platform.system() 动态切换——你不能给 Windows 用户一份仅包含 Linux 第三方组件声明的许可证,反之亦然。from e 的异常链接保留了原始 StopIteration 堆栈,让调试者能立刻定位"为什么找不到 dist-info"——是路径错了?还是 wheel 根本没装?
93.13 文档版本管理与离线包索引 —— 为文档站点生成"版本导航仪"
双产出模式:生成 doc/versions/available.rst(历史版本页面)与 doc/_static/versions.json(版本切换器配置)。数据源与采集策略:请求 GitHub API scikit-learn.github.io 仓库根目录列表,筛选数字版本目录与 dev/stable 符号链接;对每个版本目录抓取 index.html 正则提取真实版本号;再次请求 API 获取 _downloads 目录下的文档归档包大小。版本排序与去重逻辑:使用 parse_version 实现 PEP 440 语义化版本排序;符号链接复用目标目录元数据,seen 集合去重;版本切换器仅保留最新 8 个版本。
源码路径:build_tools/circle/list_versions.py - json_urlread()(19-26行)
def json_urlread(url):
try:
# ① 读取 GitHub API 响应并解析为 JSON
return json.loads(urlopen(url).read().decode("utf8"))
except Exception:
# ② 捕获所有异常:先打印到 stderr(保留上下文),再重新抛出
print("Error reading", url, file=sys.stderr)
raise
源码路径:build_tools/circle/list_versions.py - human_readable_data_quantity()(28-36行)
def human_readable_data_quantity(quantity, multiple=1024):
# https://stackoverflow.com/questions/1094841/reusable-library-to-get-human-readable-version-of-file-size
# ① 处理零字节边界情况,避免后续除法出现 NaN
if quantity == 0:
quantity = +0
# ② 根据进制选择 B / iB 后缀(1000 → SI 单位 KB, 1024 → IEC 单位 KiB)
SUFFIXES = ["B"] + [i + {1000: "B", 1024: "iB"}[multiple] for i in "KMGTPEZY"]
for suffix in SUFFIXES:
# ③ 选择合适量级:未跨级或已是最大单位时停止
if quantity < multiple or suffix == SUFFIXES[-1]:
if suffix == SUFFIXES[0]:
return "%d %s" % (quantity, suffix)
else:
return "%.1f %s" % (quantity, suffix)
else:
quantity /= multiple
源码路径:build_tools/circle/list_versions.py - get_file_extension()(30-36行)
def get_file_extension(version):
# ① dev 分支必须显式返回 zip(dev 永远是最新格式)
if "dev" in version:
# The 'dev' branch should be explicitly handled
return "zip"
# ② 其余版本通过 PEP 440 语义化版本比较判断
current_version = parse_version(version)
min_zip_version = parse_version("0.24")
# ③ >=0.24 为 zip,旧版(<0.24)为 pdf
return "zip" if current_version >= min_zip_version else "pdf"
源码路径:build_tools/circle/list_versions.py - get_file_size()(38-48行)
def get_file_size(version):
# ① 请求 _downloads 目录的 API 列表
api_url = ROOT_URL + "%s/_downloads" % version
for path_details in json_urlread(api_url):
# ② 根据版本判断归档扩展名并构造完整文件名
file_extension = get_file_extension(version)
file_path = f"scikit-learn-docs.{file_extension}"
# ③ 匹配文件名并返回格式化大小(此处 multiple=1000 使用 SI 单位)
if path_details["name"] == file_path:
return human_readable_data_quantity(path_details["size"], 1000)
源码路径:build_tools/circle/list_versions.py - 符号链接去重与版本排序(84-110行)
# 第 93 章 —— ① 初始化两个独立容器:dirs 存普通目录元数据,symlinks 存符号链接的目标指向
dirs = {}
symlinks = {}
root_listing = json_urlread(ROOT_URL)
for path_details in root_listing:
name = path_details["name"]
# ② 仅处理数字开头目录或 NAMED_DIRS(dev / stable)中的命名目录
if not (name[:1].isdigit() or name in NAMED_DIRS):
continue
if path_details["type"] == "dir":
# ③ 普通目录:抓取 index.html 提取真实版本号 + 归档大小
html = urlopen(RAW_FMT % name).read().decode("utf8")
version_num = VERSION_RE.search(html).group(1)
file_size = get_file_size(name)
dirs[name] = (version_num, file_size)
if path_details["type"] == "symlink":
# ④ 符号链接:记录 target,待后续复用元数据
symlinks[name] = json_urlread(path_details["_links"]["self"])["target"]
# 第 93 章 —— ⑤ 符号链接复用目标目录的元数据(避免重复抓取 index 与 _downloads)
# 第 93 章 —— Symlinks should have same data as target
for src, dst in symlinks.items():
if dst in dirs:
dirs[src] = dirs[dst]
# 第 93 章 —— ⑥ 按"dev → stable → 版本倒序"输出,仅前 8 个进 JSON
seen = set()
# 第 93 章 —— Output in order: dev, stable, decreasing other version
for i, name in enumerate(
NAMED_DIRS
+ sorted((k for k in dirs if k[:1].isdigit()), key=parse_version, reverse=True)
):
version_num, file_size = dirs[name]
# ⑦ seen 集合去重:符号链接可能与目标目录版本号相同
if version_num in seen:
# symlink came first
continue
else:
seen.add(version_num)
源码路径:build_tools/circle/list_versions.py - __main__ 双产出(95-175行)
# 第 93 章 —— ① 解析命令行参数:--rst 与 --json 两个输出路径
parser = argparse.ArgumentParser()
parser.add_argument("--rst", type=str, required=True)
parser.add_argument("--json", type=str, required=True)
args = parser.parse_args()
# 第 93 章 —— ② 初始化输出缓冲区(RST 头部 + JSON 列表)
heading = "Available documentation for scikit-learn"
json_content = []
rst_content = [
":orphan:\n",
heading,
"=" * len(heading) + "\n",
"Web-based documentation is available for versions listed below:\n",
]
ROOT_URL = "https://api.github.com/repos/scikit-learn/scikit-learn.github.io/contents/"
RAW_FMT = "https://raw.githubusercontent.com/scikit-learn/scikit-learn.github.io/master/%s/index.html"
VERSION_RE = re.compile(r"scikit-learn ([\w\.\-]+) documentation</title>")
NAMED_DIRS = ["dev", "stable"]
# 第 93 章 —— ③ 遍历 GitHub Pages 仓库根目录,区分普通目录与符号链接
dirs = {}
symlinks = {}
root_listing = json_urlread(ROOT_URL)
for path_details in root_listing:
name = path_details["name"]
# ③.1 仅处理数字开头目录或 NAMED_DIRS 中的命名目录
if not (name[:1].isdigit() or name in NAMED_DIRS):
continue
if path_details["type"] == "dir":
# ③.2 普通目录:抓取 index.html 提取真实版本号 + 归档大小
html = urlopen(RAW_FMT % name).read().decode("utf8")
version_num = VERSION_RE.search(html).group(1)
file_size = get_file_size(name)
dirs[name] = (version_num, file_size)
if path_details["type"] == "symlink":
# ③.3 符号链接:记录 target,待后续复用元数据
symlinks[name] = json_urlread(path_details["_links"]["self"])["target"]
# 第 93 章 —— ④ 符号链接复用目标目录的元数据(避免重复抓取 index 与 _downloads)
# 第 93 章 —— Symlinks should have same data as target
for src, dst in symlinks.items():
if dst in dirs:
dirs[src] = dirs[dst]
# 第 93 章 —— ⑤ 按"dev → stable → 版本倒序"输出,仅前 8 个进 JSON
seen = set()
# 第 93 章 —— Output in order: dev, stable, decreasing other version
for i, name in enumerate(
NAMED_DIRS
+ sorted((k for k in dirs if k[:1].isdigit()), key=parse_version, reverse=True)
):
version_num, file_size = dirs[name]
# ⑤.1 seen 集合去重:符号链接可能与目标目录版本号相同
if version_num in seen:
# symlink came first
continue
else:
seen.add(version_num)
full_name = f"{version_num}" if name[:1].isdigit() else f"{version_num} ({name})"
path = f"https://scikit-learn.org/{name}/"
# ⑥ 版本切换器 JSON:仅保留最新 8 个版本避免下拉菜单过长
# Update JSON for the version switcher; only keep the 8 latest versions to avoid
# overloading the version switcher dropdown
if i < 8:
info = {"name": full_name, "version": version_num, "url": path}
# ⑥.1 stable 标记为 preferred(默认选中)
if name == "stable":
info["preferred"] = True
json_content.append(info)
# ⑦ RST 页面:构建"版本 + 归档下载链接 + 大小"的列表项
# Printout for the historical version page
out = f"* `scikit-learn {full_name} documentation <{path}>`_"
if file_size is not None:
file_extension = get_file_extension(version_num)
out += (
f" (`{file_extension.upper()} {file_size} <{path}/"
f"_downloads/scikit-learn-docs.{file_extension}>`_)"
)
rst_content.append(out)
# 第 93 章 —— ⑧ 写出 RST 页面与 JSON 配置
with open(args.rst, "w", encoding="utf-8") as f:
f.write("\n".join(rst_content) + "\n")
print(f"Written {args.rst}")
with open(args.json, "w", encoding="utf-8") as f:
json.dump(json_content, f, indent=2)
print(f"Written {args.json}")
作为"4S 店版本墙维护员",这套脚本的精妙之处在于对符号链接的"透明复用":dirs 字典记录普通目录的元数据,symlinks 字典记录符号链接指向的目标;第二轮 for src, dst in symlinks.items() 把符号链接的元数据指针"重定向"到目标目录,避免重复抓取 index.html 与 _downloads;第三轮 seen 集合则在输出阶段拦截"同版本号重复展示"——当 stable 链接到 1.5 时,stable 的 version_num 会被先记入 seen,等到真正遍历到目录 1.5 时就被跳过。这种"先收数据、再做指针重定向、最后去重输出"的三段式设计让版本墙既不重复也不漏列。作为辅助的 human_readable_data_quantity 与 get_file_extension 则像量尺与标签打印机:量尺按 SI/IEC 单位制灵活切换,标签打印机根据版本号自动选择 zip/pdf 格式——这两位"小助手"被 get_file_size 与 __main__ 反复调用,贯穿整个版本墙构建流水线。
93.14 设计中的取舍
为什么 PR 标签选择 POST /labels 而非 issue 级 PATCH? 这是 GitHub REST API 的设计哲学:POST /repos/{owner}/{repo}/issues/{issue_number}/labels(issue.add_to_labels)接受一个标签数组,原子性地为 issue 添加多个标签;而单标签的添加既可以通过 POST /labels 也可以通过 PATCH /issues/{issue_number} 整体更新。PyGithub 的 add_to_labels(*labels) 内部封装的正是前者。这种设计的优势是支持批量操作且天然幂等(重复添加同名标签不会产生副作用);代价是无法精细控制单个标签的添加/移除语义,比如想要"仅保留这些标签、移除其他"就必须用 set_labels。scikit-learn 选择 add_to_labels 是因为标签操作是叠加式的(保留历史标签 + 新增本次匹配的),这与 PR 标签的累积语义契合。
为什么 OpenMP 校验中"源码未声明依赖"与"冗余声明"都通过 raise ValueError 抛出? 这是基于"配置一致性"的强约束:前者会让编译/链接过程失败(undefined reference to omp_*),导致 wheel 完全无法构建——这是硬阻塞,必须拦截;后者虽然让产物携带不必要的 OpenMP 运行时依赖(轻微增加二进制体积),但更严重的是反映出开发者对模块是否使用 OpenMP 存在误解,可能是 OpenMP 真的未被使用(应移除声明)或是被遗漏(应补充源码)。main() 函数把两类不一致合并到同一条错误消息中通过同一个 raise 抛出——这种"任一不一致即整体拦截"的策略传达的是"宁可误报不可漏报"的工程哲学,与 OpenMP 一旦配置错误就难以排查的特性相符。CI 会对"良性冗余"也频繁打断开发流程,但这是有意为之的"防御性冗余"——把潜在的幽灵依赖消灭在合并前,避免任何配置漂移进入主分支。
为什么版本切换器 JSON 仅保留最新 8 个版本? 这是 pydata-sphinx-theme 下拉菜单的可用性约束:过多版本会让菜单滚动过长,降低导航效率;同时保留 dev 与 stable 占据 2 个槽位,剩下 6 个数字版本槽位。这种设计的优势是 UI 简洁;代价是用户无法从下拉菜单直接跳转到超过 8 个版本之前的旧文档,必须通过 available.rst 历史页面访问——这种"核心功能常驻、完整历史可查"的双层设计兼顾了 UX 与信息完整性。
为什么 get_commit_message.py 选择简单字符串替换而非正则清洗? 源码实际使用 commit_message.replace("##vso", "..vso") 这种"暴力打散前缀"策略,而非更复杂的正则匹配(如 r'::\w+ (name|output|notice|warning|error|debug|group|endgroup|add-mask|stop-commands|continue-commands).*?::')。这种取舍的核心理由是 Azure 解析器的"前缀即指令"语义:Azure 流水线只会把整行以 ##vso[...] 开头的内容识别为任务指令,因此只要破坏前缀的前两个字符(## → ..),任何变体都无法被误判——##vso[task.setvariable ...]、##vso[task.complete]、甚至 ##vsomething 都会同时失效。简单替换的优势是 零依赖、零误杀、零性能开销,且对编码不敏感;其隐含前提是 Azure 解析器不会"猜"指令——而这个前提在 Azure DevOps 的官方文档中得到保证。代价是替换后字符串中可能仍残留 vso 子串(如 ..vso[task.setvariable ...]),但 Azure 不会再去解析它,因此无害。这种"够用就好"的设计哲学体现的是务实工程思想:不追求最优雅的清洗策略,只追求最不容易出错的清洗策略。
为什么 check_wheels.py 用 n_wheels += 1 而不是直接从 YAML 读 sdist 字段? 这是"配置即契约"的简化:GitHub Actions wheels.yml 中的 matrix.include 只列二进制 wheel(每个平台/解释器组合一项),而 sdist 是在矩阵之外由单独的 job 构建的。如果让脚本去 YAML 中解析多个 job 的存在性,会让校验逻辑与 CI 拓扑强耦合——任何 job 重命名都会让清点员罢工。+= 1 的硬编码假设反映了"sdist 永远是矩阵项 + 1"的现状约束:sdist 不会被矩阵化(没有平台/Python 版本差异),所以加 1 是最不容易出错的常量。代价是如果未来出现"额外构建第二种 sdist"的需求,这一行需要修改;但修改一行远比重构 YAML 解析器安全。
为什么 vendor.py 选择 shutil.copy2 而非 shutil.copy? 这是"可复现构建"的硬要求:wheel 在发布到 PyPI 前会被上传到 GitHub Release 等多个镜像,CI 缓存层也依赖文件内容哈希做去重。如果用 shutil.copy(仅复制内容与权限),两次构建会因系统时钟误差、umask 差异产生不同的归档哈希,让缓存命中率下降。shutil.copy2 额外保留原文件的修改时间与元数据,确保同一份源码、同一份 DLL 源文件无论何时构建都得到字节级一致的 wheel——这对测试"这个 wheel 是不是和上次完全一样"至关重要。代价是构建主机时钟必须稳定(不能跨时区随机跳变),但 CI 环境本来就运行在受控容器中,这一点不是问题。
为什么 list_versions.py 对 dev 强制返回 zip 而其他版本用 PEP 440 比较? 这是"未来兼容"的硬保障:dev 分支永远指向当前正在开发的最新文档,其归档格式与下一个正式版本的归档格式保持一致(不会因为今天还在用 zip、下个版本就变 pdf 而导致 dev 链接 404)。而对历史版本使用 parse_version("0.24") 的语义化比较则反映了"格式分水岭"——0.24 之前 scikit-learn 文档只发布 PDF(因为 zip 化的 HTML 文档构建工具链还没稳定),0.24 之后才改用 zip(HTML 文档体积更小、支持离线搜索、便于浏览器直接打开)。这种"硬编码 dev + 软比较历史"的两段式策略让脚本在面对"格式迁移"时只需改一行 min_zip_version,无需重写整个分支判断。
93.15 动手练习
-
阅读 PR 标题标签自动化脚本
-
阅读
.github/scripts/label_title_regex.py,回答问题:-
脚本如何从 GitHub Actions 环境中获取 PR 标题和编号?
-
正则映射表中
ENH、BUG、DOC等前缀分别对应哪些标签? -
为什么要使用
POST而非PATCH调用 Labels API?幂等性如何保证?
-
-
-
分析 Azure 提交消息提取与清洗机制
-
阅读
build_tools/azure/get_commit_message.py,回答问题:-
脚本按什么优先级尝试不同的环境变量获取提交信息?
-
脚本防范的是哪种攻击?此清洗策略的优势是什么?
-
如果环境变量与
git log结果不一致,脚本会如何处理?
-
-
-
理解 Lint 报告机器人评论生成
-
阅读
build_tools/get_comment.py,回答问题:-
脚本如何按工具分组生成结构化报告?
-
❌ Linting issues标题在评论幂等更新中起什么作用? -
如何通过 GitHub API 实现"找到旧评论则更新,否则创建"的逻辑?
-
-
-
探究 OpenMP 依赖一致性校验
-
阅读
build_tools/check-meson-openmp-dependencies.py,回答问题:-
git grep与meson introspect分别产出什么数据结构?如何交叉比对? -
为什么源文件集合必须是 Meson 目标集合的子集?反向冗余为什么也被整体
raise抛出? -
如果新增了一个使用 OpenMP 的
.pyx文件但忘记更新meson.build,CI 会在哪个阶段报错?
-
-
-
实验随机种子测试动态选择
-
阅读
build_tools/azure/get_selected_tests.py,回答问题:-
脚本如何复用
get_commit_message.py的输出? -
[all random seeds]标记后的多行测试名是如何被转换成 pytest-k可消费的表达式的? -
输出 JSON 中的
selected_tests字段在下游 pytest 调用中如何被消费?
-
-
-
剖析 CI 失败与 GitHub Issue 联动
-
阅读
maint_tools/update_tracking_issue.py,回答问题:-
JUnit XML 中
<testcase>的哪些子元素包含堆栈信息?如何聚合同名测试的多维度失败? -
Issue 标题中的幂等键包含哪些维度?为何不包含构建编号?
-
PATCH /issues/comments/{comment_id}与POST /issues在权限与速率限制上有何差异?
-
-
-
阅读构建产物校验脚本
-
阅读
build_tools/github/check_wheels.py,回答问题:-
脚本如何解析
.github/workflows/wheels.yml获取预期构建数量? -
为什么实际产物数量要比矩阵项数多 1(
n_wheels += 1)? -
如果数量不匹配,脚本如何阻断后续发布流程?
-
-
-
分析 Windows DLL 嵌入机制
-
阅读
build_tools/github/vendor.py,回答问题:-
make_distributor_init_64_bits生成的代码为何使用op.abspath而非相对路径加载 DLL? -
_distributor_init.py为何能比其他 Cython 扩展模块更早执行? -
shutil.copy2相比shutil.copy在复制 DLL 时有何优势? -
__main__入口如何接收并传递wheel_dirname参数?
-
-
-
理解许可证合规检查
-
阅读
build_tools/wheels/check_license.py,回答问题:-
脚本如何定位安装后的
scikit_learn-*.dist-info目录? -
断言检查的两个关键字符串分别对应什么法律要求?
-
为何使用
platform.system()区分平台相关的打包软件声明?
-
-
-
探究文档版本管理脚本
-
阅读
build_tools/circle/list_versions.py,回答问题:-
get_file_extension函数为何在版本含 'dev' 时强制返回 'zip'? -
版本排序为何使用
sklearn.utils.fixes.parse_version而非标准库packaging.version? -
符号链接(如
stable->1.5)的元数据复用与去重逻辑是如何实现的? -
版本切换器 JSON 为何仅保留最新 8 个版本?
-
json_urlread如何处理网络异常?为何打印到 stderr 而非 raise? -
human_readable_data_quantity中multiple=1024与1000的区别是什么?文档归档大小用哪个?
-
-
93.16 本章小结
这一章中我们学习了 scikit-learn 构建产物打包与发布的完整自动化体系。首先,我们剖析了 PR 标题驱动的标签自动化机制,理解如何通过正则映射让 GitHub Actions 读懂提交意图;其次,我们探索了 Azure 提交消息提取与安全清洗流程,掌握 ##vso 注入防范与跨环境统一入口的设计;接着,我们理解了 Lint 失败报告与机器人评论生成的幂等更新闭环;然后,我们深入了 OpenMP 依赖一致性校验的双轨交叉验证机制;接着,我们掌握了随机种子测试选择与提交解析的动态策略;然后,我们剖析了 CI 失败追踪与 GitHub Issue 联动的生命周期管理;接着,我们理解了构建矩阵与产物数量的对账闸口;接着,我们掌握了 Windows DLL 嵌入与运行时初始化的双重保障;接着,我们了解了 wheel 许可证合规检查的法律风险防线;最后,我们熟悉了文档版本管理的双产出模式与 GitHub API 交互细节。
为方便读者在脑海中建立索引,下面以表格方式对本章涉及的核心概念做一次系统梳理:
| 概念 | 解释 |
|------|------|
| label_title_regex.py | 解析 PR 标题正则前缀,通过 GitHub API 自动添加标签,驱动 CI 路由与变更日志分类 |
| get_commit_message.py | 从 Azure 环境变量或 git log 统一提取提交信息,清洗 GitHub Actions 命令注入标记 |
| get_comment.py | 解析 CI 日志中的 ruff/mypy 输出,生成结构化 Markdown 并在 PR 评论区幂等创建/更新 |
| check-meson-openmp-dependencies.py | git grep 扫描源码 OpenMP 使用与 meson introspect 解析构建依赖交叉比对,防止构建配置漂移 |
| get_selected_tests.py | 检测提交信息中的 [all random seeds] 标记,动态决定是否运行全量随机种子测试矩阵 |
| update_tracking_issue.py | 解析 JUnit XML 聚合失败测试,通过 GitHub API 自动创建/更新/关闭追踪 Issue |
| check_wheels.py | 对账 CI 构建矩阵与实际产物数量,防止构建任务丢失 |
| vendor.py | 嵌入 MSVC 运行时 DLL 并生成预加载钩子,解决 Windows 下 OpenMP 依赖缺失 |
| _distributor_init.py | 包导入时最先执行,用 ctypes.WinDLL 预加载 .libs 目录下的 DLL |
| check_license.py | 验证 wheel 中 licenses/COPYING 存在性与内容完整性,落实法律合规 |
| list_versions.py | 从 GitHub API 采集版本信息,生成历史版本 RST 页面与版本切换器 JSON |
| 版本切换器 | pydata-sphinx-theme 的下拉菜单数据源,仅保留最新 8 个版本 |
| 符号链接去重 | stable/dev 复用目标目录元数据,seen 集合避免重复展示 |
| json_urlread | 统一的 GitHub API JSON 读取封装,含异常捕获与错误上下文打印 |
| human_readable_data_quantity | 字节数转人类可读字符串(KB/MB/GB),支持 1000/1024 进制切换 |
| get_file_extension | 根据版本字符串判断文档归档格式:dev 与 >=0.24 为 zip,其余为 pdf |
| get_file_size | 请求 _downloads API 匹配归档包名并返回格式化大小 |
第 94 章 —— 维护工具链
94.1 学习目标
-
难度:★★★☆☆(3/5)
-
预备知识:Python 基础、面向对象编程与 Markdown/代码阅读基础
-
理解 scikit-learn 维护工具链的设计理念与自动化流程
-
掌握依赖版本升级策略(Python 3年规则、编译型依赖最老兼容 Wheel、纯 Python 依赖2年规则)的实现原理
-
熟悉 What's New 条目的自动分类、标签优先级排序与 reStructuredText 输出生成机制
-
理解基于 GitHub 标签时间戳的 PR 自动关闭策略与干运行安全机制
-
掌握从 GitHub 团队 API 获取成员、身份去重分层、资料渲染生成贡献者文档页面的全流程
-
了解 xfail 测试标记有效性检查的双向校验逻辑(意外失败与预期未触发)及其在 CI 中的质量闸门作用
94.2 生活类比
想象 scikit-learn 的维护工具链是一支专业的"项目管家团队"。这支团队没有光鲜的前端界面,却承担着让整个项目稳健运转的幕后职责,每一位"管家"都各司其职,用脚本代替人工,把繁琐的维护工作变成可重复、可审计的流水线。每天的晨会从依赖版本升级开始,"版本策略分析师"翻阅 Python 官方发布日历,应用"3年规则"算出最低支持版本;接着查询 PyPI 仓库库存,确认哪些编译型依赖有兼容当前 Python 的 Wheel;再对纯 Python 包应用"2年规则"挑选新版本,最后输出《依赖升级建议报告》。晨会结束后,这位分析师还要兼顾各种不同的策略权衡:它必须在"稳定兼容旧环境"与"拥抱新特性"这对天然矛盾之间走钢丝——给编译型依赖选最老的版本,是因为用户群体里总有人用着十年前的发行版;而给纯 Python 依赖选最新的版本,则是因为无需编译就能享受到性能改进的福利。这种"因地制宜"的策略组合,是科学发布决策的精髓,也是整支管家团队每天工作的起点。
紧接着出场的是"发布记者",它从 stdin 接收维护者投递的零散稿件。这位记者用正则识别条目中的 Sphinx 角色引用,把它们自动归类到"linear_model"、"preprocessing"、"ensemble"等模块版块,再按"重大功能 > 新功能 > 性能 > 增强 > 修复 > API"的标签优先级排版,直接吐出排版整齐的 RST 版面,省去繁琐的手工编辑。它的存在让原本枯燥的 changelog 整理工作变成轻松的"投递-接收"流程,也成为管家团队在版本发布前最倚重的文本处理帮手。
每日午后,"守门人"开始巡逻。它翻阅贴有"autoclose"标签的 PR,确认最近一次贴标签日期,超期 14 天未整改者,按流程留言告知并关闭大门。这条规则既不会对一时沉寂的 PR 赶尽杀绝,也不会让真正"放弃治疗"的 PR 永远挂着标签占用视线。这位守门人还自带"演练模式"(dry-run),让 CI 可以先做安全预演而不执行实际操作,避免误伤那些突然回归的活跃贡献者,同时也以可审计的日志形式向运维人员提供完整的执行轨迹。
"名册编纂官"的工作则像图书馆的目录员,从 GitHub 组织架构里调取核心开发、体验团队、沟通团队、文档团队四个团队,按"身份唯一、分层互斥"原则整理花名册。它会补录无账号元老、剔除 CI 机器人,最后分别制作"带头像精美版"(HTML 表格)与"纯文字版"(RST 列表)两套名册,纳入文档构建。这种"两套名册"的设计让不同文档页面各取所需——主页面要视觉冲击,荣誉页要信息密度——编纂官在补录历史贡献者时还要应对 GitHub 接口限流、姓名缺失、头像缺失等边缘情况,靠多层防御机制保证每一行名册都干净可用。
最后登场的"质检员"是质量底线的守护者。它逐个把估计器送上"体检台"(check_estimator),屏蔽常规干扰(skip/xfail 处理),实测"哪些真挂了"、"哪些本该挂却过了"。前者报警"可能引入新 Bug",后者通知"xfail 标签已失效,速速移除"。这个看似简单的"双向核对"机制,却守住了测试套件的"零虚假通过"底线——任何被掩盖的 Bug 都会在升级环境后浮出水面,而任何失效的 xfail 标记都会成为隐藏的陷阱。质检员以静默方式运行检查,只在发现异常时大声报警,是管家团队在测试质量防线上最可靠的最后一道闸门。
94.3 源码地图
maint_tools/bump-dependencies-versions.py
├── get_min_python_version() # 依据 Python 官方发布历史,执行 3 年规则计算最低 Python 版本
├── get_min_version_with_wheel() # 查询 PyPI JSON API,寻找提供指定 Python 版本 Wheel 的最早 X.Y.0 版本
├── get_min_version_pure_python_or_example_dependency() # 针对纯 Python 包,按 2 年规则选取最新兼容小版本
├── get_current_dependencies_version() # 调用 sklearn/_min_dependencies.py 读取当前锁定的依赖版本
├── get_current_min_python_version() # 解析 pyproject.toml 中的 requires-python 字段
├── show_versions_update() # 汇总对比当前与未来版本,仅打印有变更的依赖项
└── __main__ # 入口:接受可选发布日期参数,驱动版本分析流程
maint_tools/sort_whats_new.py
├── entry_sort_key() # 依据 LABEL_ORDER 解析条目标签并返回排序键
└── __main__ # 入口:读取 stdin,正则提取模块名分桶,桶内排序并输出 RST 格式
build_tools/github/autoclose_prs.py
├── get_labeled_last_time() # 遍历 PR 事件时间线,获取最近一次添加 autoclose 标签的时间
└── __main__ # 入口:查询带标签 PR,按时间窗口筛选,干运行模式下仅打印,实运行下评论并关闭
build_tools/generate_authors_table.py
├── get() # 带指数退避重试的 GitHub API 请求封装
├── get_contributors() # 核心逻辑:获取 4 个官方团队成员 + 全体成员,集合运算去重分层,手动修正集
├── get_profile() # 调用 /users/{login} 获取真实姓名、头像、主页,含缺失姓名修正表
├── key() # 按姓氏首字母、名字排序的键函数
├── generate_table() # 生成包含 HTML/CSS 的 raw::html 指令(核心团队等需头像页面)
├── generate_list() # 生成纯 RST 列表(荣誉名册页面)
└── __main__ # 入口:交互式输入凭据,调用上述流程生成 7 个 .rst 文件至 doc/
maint_tools/check_xfailed_checks.py
└── __main__ # 入口:遍历 _tested_estimators,禁用 skip/fail 处理运行 check_estimator,双向集合差集校验 xfail 标记有效性
94.4 依赖版本升级策略分析 —— 基于 Python 版本与 Wheel 可用性的版本计算器
scikit-learn 每次发布前都需要决定两件事:最低支持哪个 Python 版本?各项依赖的最小版本号定多少?这个看似简单的决策其实暗藏权衡:定得太低会迫使用户升级或源码编译,定得太高又会流失兼容旧环境的用户。bump-dependencies-versions.py 就是帮维护者自动化这个决策过程的"版本策略分析师"。
脚本根据依赖类型应用三种差异化策略。Python 版本本身采用"3 年规则",即只停止支持那些发布超过 3 年的 Python 版本。编译型依赖(NumPy、SciPy 等含 C 扩展)走"最老兼容 Wheel"路线,查询 PyPI 寻找提供当前最低 Python 版本 Wheel 的最早 X.Y.0 版本,避免用户被迫从源码编译。纯 Python 依赖则相反,采用"2 年规则"选择最新的兼容小版本,优先享受新功能与性能改进。除了三个核心策略函数,脚本还需要从本地仓库读取当前锁定的版本号——这分别由 get_current_dependencies_version 与 get_current_min_python_version 负责:前者通过子进程调用 sklearn/_min_dependencies.py 取得单个依赖的最低版本字符串,后者直接用正则从 pyproject.toml 中提取 requires-python 字段。最后由 __main__ 入口解析命令行参数,把发布日期透传给汇总函数 show_versions_update,完成整个版本分析流程。
下面我们逐个拆解这些策略函数的核心实现。
源码路径:maint_tools/bump-dependencies-versions.py - get_min_version_with_wheel()(22-44行)
def get_min_version_with_wheel(package_name, python_version): # ① 函数定义:输入包名与目标 Python 版本
# For compiled dependencies we want the oldest minor version that has
# wheels for 'python_version'
url = f"https://pypi.org/pypi/{package_name}/json" # ② 构造 PyPI JSON API URL
response = requests.get(url) # ③ 发起 HTTP GET 请求获取包元数据
if response.status_code != 200: # ④ 检查 HTTP 状态码,非 200 直接返回 None
return None
data = response.json() # ⑤ 将响应内容解析为 JSON 字典
releases = data["releases"] # ⑥ 取出"版本号 -> 发布文件列表"字典
compatible_versions = [] # ⑦ 初始化候选版本列表
# We want only minor X.Y.0 and not bugfix X.Y.Z
minor_releases = [ # ⑧ 过滤:只保留 X.Y.0 小版本(排除 X.Y.Z 修复版)
(ver, release_info)
for ver, release_info in releases.items()
if re.match(r"^\d+\.\d+\.0$", ver)
]
for ver, release_info in minor_releases: # ⑨ 遍历每个小版本号
for file_info in release_info: # ⑩ 遍历该版本的所有发布文件
if (
file_info["packagetype"] == "bdist_wheel" # ⑪ 仅接受 wheel 格式(避免源码包)
and f"cp{python_version.replace('.', '')}" # ⑫ 文件名须包含目标 Python 版本的 cp 标签
in file_info["filename"]
and not file_info["yanked"] # ⑬ 排除已被 PyPI 撤回的版本
):
compatible_versions.append(ver) # ⑭ 命中条件则加入候选列表
break # ⑮ 该版本的第一个匹配 wheel 即足够,跳出内层循环
if not compatible_versions: # ⑯ 若无任何候选版本则返回 None
return None
return min(compatible_versions, key=version.parse) # ⑰ 用 packaging.version 解析后取最小值(最老版本)
这段代码定义了 get_min_version_with_wheel 函数,针对编译型依赖(如 NumPy)查询 PyPI JSON API,寻找提供指定 Python 版本 Wheel 的最早 X.Y.0 版本。它先获取包的所有发布记录,过滤出 X.Y.0 格式的小版本(而非 X.Y.Z 修复版),再检查每个版本的发布文件,只有同时满足 wheel 格式、文件名包含目标 Python 版本(cp{python_version})且未撤回的,才被纳入候选列表。最终用 min() 选出最早的版本,因为编译型依赖需要最广兼容性。
源码路径:maint_tools/bump-dependencies-versions.py - get_min_python_version()(46-65行)
def get_min_python_version(scikit_learn_release_date_str="today"): # ① 函数定义:默认以今天为发布日期
# min Python version is the most recent Python release at least 3 years old
# at the time of the scikit-learn release
if scikit_learn_release_date_str == "today": # ② 判断发布日期参数
scikit_learn_release_date = pd.to_datetime(datetime.now().date()) # ③ 若为 today 则取当前日期
else:
scikit_learn_release_date = datetime.strptime( # ④ 否则按 YYYY-MM-DD 格式解析
scikit_learn_release_date_str, "%Y-%m-%d"
)
version_and_releases = [ # ⑤ 筛选发布超过 3 年的 Python 版本
{"python_version": python_version, "python_release_date": python_release_date}
for python_version, python_release_date in python_version_info.items()
if (scikit_learn_release_date - python_release_date).days > 365 * 3
]
return max(version_and_releases, key=lambda each: each["python_release_date"])[ # ⑥ 取发布时间最晚者(最年轻的"老版本")
"python_version"
]
这段代码定义了 get_min_python_version 函数,依据 Python 官方发布历史执行"3 年规则"。它从模块顶部 pandas.read_html 抓取的 Python 版本发布表中,找出所有发布时间距发布日期超过 3 年的 Python 版本,再从中选出发布时间最晚的那一个作为最低要求。逻辑是:保留那些已经发布很久(成熟稳定)的版本中最新的,但放弃太新的(3 年内的)以确保生态兼容。
源码路径:maint_tools/bump-dependencies-versions.py - get_min_version_pure_python_or_example_dependency()(67-100行)
def get_min_version_pure_python_or_example_dependency(
package_name, scikit_learn_release_date_str="today" # ① 函数定义:包名与发布日期
):
# for pure Python dependencies we want the most recent minor release that
# is at least 2 years old
if scikit_learn_release_date_str == "today": # ② 解析发布日期(同上)
scikit_learn_release_date = pd.to_datetime(datetime.now().date())
else:
scikit_learn_release_date = datetime.strptime(
scikit_learn_release_date_str, "%Y-%m-%d"
)
url = f"https://pypi.org/pypi/{package_name}/json" # ③ 构造 PyPI API URL
response = requests.get(url) # ④ 发起请求
if response.status_code != 200: # ⑤ HTTP 失败兜底
return None
data = response.json() # ⑥ 解析 JSON
releases = data["releases"] # ⑦ 取出发布字典
compatible_versions = [] # ⑧ 初始化候选列表
releases = [ # ⑨ 筛选 X.Y.0 小版本
(ver, release_info)
for ver, release_info in releases.items()
if re.match(r"^\d+\.\d+\.0$", ver)
]
for ver, release_info in releases: # ⑩ 遍历每个小版本
for file_info in release_info: # ⑪ 遍历该版本的发布文件
if (
file_info["packagetype"] == "bdist_wheel" # ⑫ 仍要求 wheel 格式(统一接口)
and not file_info["yanked"] # ⑬ 排除撤回版本
and ( # ⑭ 但额外要求上传时间超 2 年("陈旧"原则)
scikit_learn_release_date - pd.to_datetime(file_info["upload_time"])
).days
> 365 * 2
):
compatible_versions.append(ver) # ⑮ 收录符合"陈旧"条件的版本
break # ⑯ 跳出内层循环
if not compatible_versions: # ⑰ 兜底返回
return None
return max(compatible_versions, key=version.parse) # ⑱ 取最大(最新)版本——与编译型相反!
这段代码定义了 get_min_version_pure_python_or_example_dependency 函数,针对纯 Python 依赖(如 joblib)按"2 年规则"选取最新兼容小版本。与编译型依赖的逻辑形成鲜明对比:编译型用 min()(最老),纯 Python 用 max()(最新)。原因是纯 Python 包不需要用户编译,可以放心用新版本享受功能改进。
源码路径:maint_tools/bump-dependencies-versions.py - get_current_dependencies_version()(102-105行)
def get_current_dependencies_version(dep): # ① 函数定义:输入依赖名
return ( # ② 通过子进程调用 sklearn/_min_dependencies.py
subprocess.check_output([sys.executable, "sklearn/_min_dependencies.py", dep])
.decode()
.strip()
) # ③ 解码 UTF-8 并去除首尾空白后返回字符串
这段代码定义了 get_current_dependencies_version 函数,借助 sklearn/_min_dependencies.py 这一"权威源"读取当前锁定的依赖最低版本。它用 subprocess.check_output 启动一个子进程,传入当前 Python 解释器路径、目标脚本路径与依赖名作为参数。子进程把版本字符串打印到 stdout,父进程捕获后 .decode().strip() 得到干净的版本号。这种"调用自身仓库脚本"的设计避免了重复实现版本解析逻辑——_min_dependencies.py 内部用 pandas.read_csv 读取 min_dependency_table.csv,是单一可信源(single source of truth)。
源码路径:maint_tools/bump-dependencies-versions.py - get_current_min_python_version()(107-111行)
def get_current_min_python_version(): # ① 函数定义:无参数
content = Path("pyproject.toml").read_text() # ② 读取 pyproject.toml 全部内容
min_python = re.findall( # ③ 正则提取 requires-python 字段中的版本号
r'requires-python\s*=\s*">=(\d+\.\d+)"', content
)[0] # ④ 取第一个匹配项
return min_python # ⑤ 返回如 "3.10" 的版本字符串
这段代码定义了 get_current_min_python_version 函数,从 pyproject.toml 中读取当前最低 Python 版本要求。requires-python = ">=3.10" 是 PEP 621 标准字段,脚本用 re.findall 配合捕获组 (\d+\.\d+) 提取出版本号。这一实现轻量、无第三方依赖,比 tomli/tomllib 解析更简洁——因为脚本只关心一个字段,正则足够。
源码路径:maint_tools/bump-dependencies-versions.py - show_versions_update()(113-146行)
def show_versions_update(scikit_learn_release_date="today"): # ① 函数定义:汇总报告生成器
future_versions = {"python": get_min_python_version(scikit_learn_release_date)} # ② 先算 Python 最低版本
compiled_dependencies = [ # ③ 定义编译型依赖清单
"numpy", "scipy", "pandas", "matplotlib", "pyamg", "polars", "pyarrow",
]
future_versions.update( # ④ 对每个编译型依赖求最早兼容 wheel 版本
{
dep: get_min_version_with_wheel(dep, future_versions["python"])
for dep in compiled_dependencies
}
)
pure_python_or_example_dependencies = [ # ⑤ 定义纯 Python 依赖清单
"joblib", "threadpoolctl", "scikit-image", "seaborn",
"polars", "Pillow", "pooch", "plotly",
]
future_versions.update( # ⑥ 对每个纯 Python 依赖求最新兼容版本
{
dep: get_min_version_pure_python_or_example_dependency(
dep, scikit_learn_release_date
)
for dep in pure_python_or_example_dependencies
}
)
current_versions = {"python": get_current_min_python_version()} # ⑦ 读取当前 Python 版本
current_versions.update( # ⑧ 读取当前所有依赖版本
{
dep: get_current_dependencies_version(dep)
for dep in compiled_dependencies + pure_python_or_example_dependencies
}
)
print(f"For future release at date {scikit_learn_release_date}") # ⑨ 打印报告标题
for k in future_versions: # ⑩ 遍历所有未来版本项
if future_versions[k] != current_versions[k]: # ⑪ 仅当未来版本与当前不一致时才输出
print(f"- {k}: {current_versions[k]} -> {future_versions[k]}") # ⑫ 展示迁移方向
这段代码定义了 show_versions_update 函数,是整个脚本的"报告生成器"。它先调用 get_min_python_version 计算目标 Python 版本,再对编译型依赖列表调用 get_min_version_with_wheel、对纯 Python 依赖列表调用 get_min_version_pure_python_or_example_dependency,分别得到未来版本的依赖图。同时通过 get_current_min_python_version 读取 pyproject.toml 中的 requires-python、通过 get_current_dependencies_version 调用 sklearn/_min_dependencies.py 读取当前锁定版本。最后只打印有差异的项,让维护者一眼看出哪些该改。
源码路径:maint_tools/bump-dependencies-versions.py - __main__(148-150行)
if __name__ == "__main__": # ① 入口守护
scikit_learn_release_date = sys.argv[1] if len(sys.argv) > 1 else "today" # ② 取首个命令行参数作为发布日期,缺省 "today"
show_versions_update(scikit_learn_release_date) # ③ 驱动版本分析流程
这段代码是脚本的入口,仅三行便完成参数解析与流程触发。sys.argv[1] 让用户可以在执行时传入未来某个发布日期(如 2026-01-01),用于预演依赖升级结果;缺省时取 "today",代表当前日期。这种"参数可选"的设计让脚本既能用于当下决策,也能用于未来发布的预演,体现了工具的灵活性。
整体策略可以用下面的流程图概括:
94.5 What's New 条目自动分类与排序 —— 变更日志的"智能编辑器"
每次发布前,维护者需要整理散落在各 PR 描述、issue 评论中的变更条目到 whats_new/vX.Y.Z.rst。这个过程既枯燥又容易出错:条目顺序、模块归类、标签分级都需要人工判断。sort_whats_new.py 用 50 行代码自动化这个流程,从 stdin 读入零散条目,自动按模块分桶、按标签优先级排序,直接输出可粘贴的 RST 内容。
脚本的核心逻辑包含三个关键步骤:输入源、模块归类与排序、输出格式。输入源方面,脚本通过 sys.stdin.read() 接收任意来源的文本流:维护者可以复制 PR 描述、issue 评论,从文本编辑器粘贴,也可以用 shell 管道从 gh pr list 抓取条目,Unix 风格让工具与生态其他命令天然组合。模块归类环节先用正则从条目的 Sphinx 角色引用中提取顶层模块名(如 linear_model、ensemble),按模块名分桶后用 LABEL_ORDER 列表定义 MajorFeature > Feature > Efficiency > Enhancement > Fix > API 的优先级在桶内排序。多模块/无模块处理逻辑也很巧妙:若一条目同时引用了多个 sklearn 子模块(如 linear_model 和 preprocessing),它会被归入 Multiple modules 桶;完全无 Sphinx 角色引用的条目(如纯文本说明)则归入 Miscellaneous 桶。输出格式最终是标准的 reStructuredText:每个模块桶先输出 RST 标题(文字 + 等长 . 下划线),再追加排序后的条目,桶与桶之间用两个空行分隔,可直接粘贴到 whats_new/vX.Y.Z.rst。
源码路径:maint_tools/sort_whats_new.py - entry_sort_key()(11-18行)
LABEL_ORDER = ["MajorFeature", "Feature", "Efficiency", "Enhancement", "Fix", "API"]
def entry_sort_key(s): # ① 排序键函数:输入条目字符串
if s.startswith("- |"): # ② 检查条目是否以 "- |" 开头(带标签条目)
return LABEL_ORDER.index(s.split("|")[1]) # ③ 取第二个 "|" 段(即标签),查 LABEL_ORDER 索引
else:
return -1 # ④ 无标签条目返回 -1 排最后
这段代码定义了 entry_sort_key 排序键函数。它假设条目以 - |标签| 内容 格式开头,从第二个 | 取前取标签字符串,在 LABEL_ORDER 列表中查找索引作为排序键。索引越小越靠前,因此 MajorFeature(索引0)最优先,API(索引5)最后。无标签条目返回 -1,Python 排序时小于任何合法索引,因此自动落到桶尾。
源码路径:maint_tools/sort_whats_new.py - __main__(20-55行)
# 第 94 章 —— discard headings and other non-entry lines
text = "".join(l for l in sys.stdin if l.startswith("- ") or l.startswith(" ")) # ① 过滤输入:仅保留以 "- " 或缩进开头的行
bucketed = defaultdict(list) # ② 创建桶字典(默认值为空列表)
for entry in re.split("\n(?=- )", text.strip()): # ③ 按"以 - 开头的行"作为分隔符切割条目
modules = re.findall( # ④ 正则匹配所有 Sphinx 角色引用
r":(?:func|meth|mod|class):`(?:[^<`]*<|~)?(?:sklearn.)?([a-z]\w+)", entry # ⑤ 提取 sklearn. 后顶层模块名
)
modules = set(modules) # ⑥ 用 set 去重
if len(modules) > 1: # ⑦ 命中多模块则归入"Multiple modules"桶
key = "Multiple modules"
elif modules: # ⑧ 单一模块用 :mod:`sklearn.xxx` 形式作桶名
key = ":mod:`sklearn.%s`" % next(iter(modules))
else: # ⑨ 无引用归入"Miscellaneous"桶
key = "Miscellaneous"
bucketed[key].append(entry) # ⑩ 追加到对应桶
entry = entry.strip() + "\n" # ⑪ 规范化条目(去尾随空白并加换行)
everything = [] # ⑫ 初始化最终输出列表
for key, bucket in sorted(bucketed.items()): # ⑬ 按桶名字典序遍历
everything.append(key + "\n" + "." * len(key)) # ⑭ 构造 RST 标题(文字 + 等长下划线)
bucket.sort(key=entry_sort_key) # ⑮ 桶内按标签优先级排序
everything.extend(bucket) # ⑯ 将排序后的条目加入总列表
print("\n\n".join(everything)) # ⑰ 桶间空两行输出整体 RST
这段代码是脚本主流程,完成"读取 → 分桶 → 排序 → 输出"的完整流水线。第⑤行的正则是模块归类的核心:它匹配 :func:、:meth:、:mod:、:class: 四种 Sphinx 角色引用后的反引号内容,提取 sklearn. 后的顶层模块名。第⑬行按桶名字典序遍历,让 Multiple modules、Miscellaneous 按字母排到合适位置;第⑮行在桶内用 entry_sort_key 完成标签优先级排序。整个流程不依赖任何网络请求,纯字符串处理,毫秒级完成。
下面是分桶与排序的流程图:
94.6 过期 PR 自动关闭机制 —— 基于标签时间戳的"守门人"
scikit-learn 仓库每天都会涌入各种 PR:有的完成了,有的半途而废,有的等作者反馈。为了保持仓库整洁,项目维护者使用 autoclose 标签作为"待关闭"标记:如果一个 PR 被加上此标签后超过 14 天仍无实质进展,就自动关闭并留言告知。autoclose_prs.py 就是执行这个流程的脚本。
脚本的设计涉及触发条件、安全与幂等设计、执行流程三个层面。触发条件方面,该脚本由 .github/workflows/autoclose-schedule.yml 定时工作流调用——在 GitHub Actions 中配置 cron 表达式(如 0 9 * * 1 表示每周一上午 9 点)即可实现周期性巡逻,无需人工介入。安全与幂等设计体现在三个细节上:第一,dry_run = False 硬编码为实运行(原因将在后文详述),但 if not dry_run 守护允许 CI 通过环境变量或工作流参数覆盖为预演模式;第二,使用 PyGithub 库而非裸 HTTP 调用——pr.create_comment(message) 与 pr.edit(state="closed") 是 PyGithub 提供的标准化方法,避免手写 GitHub API 路径与状态码处理,同时自动处理分页与限流;第三,仅关闭 PR 不处理 Issue——each.pull_request is not None 显式过滤,因为 GitHub API 把 PR 当作"特殊 Issue"通过 /issues 端点返回,必须剥离纯 Issue,避免误关讨论帖。整个流程还具有良好的幂等性:相同输入多次执行结果相同,再次运行只会处理"贴标签超过 14 天"的 PR,已关闭的不在标签列表中。
脚本的关键设计是基于标签时间戳的窗口判定。它不直接用 PR 的创建时间或最后提交时间,而是用"最近一次被添加 autoclose 标签的时间",这避免误关那些最近还在活跃的 PR。同时通过 dry_run 模式提供安全演练,让 CI 可以先预览将要关闭的列表。
源码路径:build_tools/github/autoclose_prs.py - get_labeled_last_time()(12-20行)
def get_labeled_last_time(pr, label): # ① 函数定义:输入 PR 对象与目标标签名
labeled_time = datetime.max # ② 默认值为 datetime.max(未来最远时间)
for event in pr.get_events(): # ③ 遍历 PR 的所有事件(标签、评论、提交等)
if event.event == "labeled" and event.label.name == label: # ④ 仅匹配"贴标签"事件且标签名匹配
labeled_time = event.created_at # ⑤ 覆盖为本次贴标签时间(最后一次循环结束即最近一次)
return labeled_time # ⑥ 返回最近一次贴标签时间
这段代码定义了 get_labeled_last_time 函数,遍历 PR 事件时间线,定位最近一次添加 autoclose 标签的时间。关键在第②行:初始值设为 datetime.max(未来最远时间),如果 PR 从未贴过此标签,计算时间窗口时会因 now - datetime.max 为负值而被自然过滤掉。第⑤行的覆盖赋值是关键:循环结束时变量保留的是最后一次匹配的时间戳,因为每次匹配都会覆盖前一次。
源码路径:build_tools/github/autoclose_prs.py - __main__(22-65行)
dry_run = False # ① 干运行开关(False 表示实运行)
cutoff_days = 14 # ② 关闭窗口天数阈值
gh_repo = "scikit-learn/scikit-learn" # ③ 目标仓库
github_token = os.getenv("GITHUB_TOKEN") # ④ 从环境变量读取 token
auth = Auth.Token(github_token) # ⑤ 构造认证对象
gh = Github(auth=auth) # ⑥ 构造 GitHub 客户端
repo = gh.get_repo(gh_repo) # ⑦ 获取仓库对象
now = datetime.now(timezone.utc) # ⑧ 当前 UTC 时间(用于时间窗口比较)
label = "autoclose" # ⑨ 目标标签名
prs = [
each for each in repo.get_issues(labels=[label]) if each.pull_request is not None # ⑩ 仅保留 PR(剔除纯 Issue)
]
prs_info = [f"{pr.title}: {pr.html_url}" for pr in prs] # ⑪ 构造可读列表
print(f"Found {len(prs)} opened PRs with label {label}") # ⑫ 打印所有候选 PR 数
pprint(prs_info) # ⑬ 美观打印候选列表
prs = [ # ⑭ 时间窗口过滤
pr
for pr in prs
if (now - get_labeled_last_time(pr, label)) > timedelta(days=cutoff_days) # ⑮ 仅保留贴标签超过 14 天的 PR
]
prs_info = [f"{pr.title} {pr.html_url}" for pr in prs] # ⑯ 构造待关闭列表
print(f"Found {len(prs)} PRs to autoclose") # ⑰ 打印待关闭数
pprint(prs_info) # ⑱ 美观打印待关闭列表
message = ( # ⑲ 标准化关闭留言
"Thank you for your interest in contributing to scikit-learn, but we cannot "
"accept your contribution as this pull request does not meet our development "
"standards.\n\n"
"Following our autoclose policy, we are closing this PR after allowing two "
"weeks time for improvements.\n\n"
"Thank you for your understanding. If you think your PR has been closed "
"by mistake, please comment below."
)
for pr in prs: # ⑳ 遍历待关闭 PR
print(f"Closing PR #{pr.number} with comment") # ㉑ 打印操作日志
if not dry_run: # ㉒ 干运行模式则不实际操作
pr.create_comment(message) # ㉓ 发表评论
pr.edit(state="closed") # ㉔ 关闭 PR
这段代码是脚本主流程,从 GitHub API 拉取带 autoclose 标签的 PR 列表,经时间窗口筛选后批量关闭。第⑩行用 pull_request is not None 过滤掉 Issue(GitHub API 把 PR 当作特殊 Issue 返回)。第⑮行是窗口判定核心:(now - get_labeled_last_time(...)) > timedelta(days=14),只有贴标签超过 14 天的才会被筛选出来。第⑲行的标准化留言包含政策说明、宽限期解释与申诉渠道,对贡献者保持尊重。第㉒行的 if not dry_run 守护提供安全演练。
下面是 PR 自动关闭的状态机:
94.7 贡献者名单自动生成 —— 从 GitHub 团队到文档页面的"名册生成器"
scikit-learn 文档站点的 doc/whos_who.rst 列出了项目的核心开发者、贡献者体验团队、沟通团队、文档团队等成员。这些信息分散在 GitHub 团队设置中,手工同步极易遗漏。generate_authors_table.py 自动从 GitHub API 拉取团队成员,按"身份唯一、分层互斥"原则整理,最终输出 7 个 .rst 文件供 Sphinx 构建。
脚本的设计亮点是集合运算 + 手动修正集的混合策略。它先用集合差集实现分层(核心 > 团队 > 荣誉),再用硬编码的小集合修补边缘情况(无 GitHub 账号贡献者、CI 机器人、归属修正)。
源码路径:build_tools/generate_authors_table.py - get()(25-37行)
def get(url): # ① 函数定义:带重试的 GET 封装
for sleep_time in [10, 30, 0]: # ② 指数退避:依次等待 10s → 30s → 0s
reply = requests.get(url, auth=auth) # ③ 发起带认证的请求
api_limit = ( # ④ 判断是否触发 GitHub 限流
"message" in reply.json()
and "API rate limit exceeded" in reply.json()["message"]
)
if not api_limit: # ⑤ 未限流则跳出重试循环
break
print("API rate limit exceeded, waiting..") # ⑥ 打印等待提示
time.sleep(sleep_time) # ⑦ 等待指定秒数
reply.raise_for_status() # ⑧ 三次重试后仍失败则抛出 HTTP 错误
return reply # ⑨ 返回响应对象
这段代码定义了 get 函数,封装带指数退避重试的 GitHub API 请求。第②行的循环是关键:先睡 10 秒、再睡 30 秒、最后不睡(直接放弃)。GitHub 对未认证请求有严格的速率限制(每小时 60 次),遇到限流时重试有奇效。第⑧行的 raise_for_status 在三次重试后仍失败时抛出 HTTP 错误,避免静默吞掉请求异常。
源码路径:build_tools/generate_authors_table.py - get_contributors()(39-101行)
def get_contributors(): # ① 函数定义:拉取并整理所有贡献者
"""Get the list of contributor profiles. Require admin rights."""
# get core devs and contributor experience team
core_devs = [] # ② 初始化四个团队的成员列表
documentation_team = []
contributor_experience_team = []
comm_team = []
core_devs_slug = "core-devs" # ③ 定义各团队的 GitHub slug
contributor_experience_team_slug = "contributor-experience-team"
comm_team_slug = "communication-team"
documentation_team_slug = "documentation-team"
entry_point = "https://api.github.com/orgs/scikit-learn/" # ④ GitHub API 入口 URL
for team_slug, lst in zip( # ⑤ 并行遍历团队 slug 与对应列表
(
core_devs_slug,
contributor_experience_team_slug,
comm_team_slug,
documentation_team_slug,
),
(core_devs, contributor_experience_team, comm_team, documentation_team),
):
print(f"Retrieving {team_slug}\n") # ⑥ 打印正在拉取的团队
for page in [1, 2]: # 30 per page # ⑦ 分两页拉取(每页 30 个成员)
reply = get(f"{entry_point}teams/{team_slug}/members?page={page}")
lst.extend(reply.json()) # ⑧ 将 JSON 响应追加到列表
# get members of scikit-learn on GitHub
print("Retrieving members\n") # ⑨ 拉取组织全体成员
members = []
for page in [1, 2, 3]: # 30 per page # ⑩ 分三页拉取全体成员
reply = get(f"{entry_point}members?page={page}")
members.extend(reply.json())
# keep only the logins # ⑪ 仅保留 login 字段(集合化去重)
core_devs = set(c["login"] for c in core_devs)
documentation_team = set(c["login"] for c in documentation_team)
contributor_experience_team = set(c["login"] for c in contributor_experience_team)
comm_team = set(c["login"] for c in comm_team)
members = set(c["login"] for c in members)
# add missing contributors with GitHub accounts # ⑫ 补录有账号但不在组织成员列表的元老
members |= {"dubourg", "mbrucher", "thouis", "jarrodmillman"}
# add missing contributors without GitHub accounts # ⑬ 补录无 GitHub 账号的贡献者
members |= {"Angel Soler Gollonet"}
# remove CI bots # ⑭ 剔除 CI 机器人
members -= {"sklearn-ci", "sklearn-wheels", "sklearn-lgtm"}
contributor_experience_team -= (
core_devs # remove ogrisel from contributor_experience_team
) # ⑮ 修正归属重叠(ogrisel 同时在两团队)
emeritus = ( # ⑯ 计算荣誉成员:总成员减去所有团队
members
- core_devs
- contributor_experience_team
- comm_team
- documentation_team
)
# hard coded # ⑰ 硬编码两个特殊归属的荣誉成员
emeritus_contributor_experience_team = {"cmarmo"}
emeritus_comm_team = {"reshamas"}
# Up-to-now, we can subtract the team emeritus from the original emeritus
emeritus -= emeritus_contributor_experience_team | emeritus_comm_team # ⑱ 从荣誉中剔除特殊归属
comm_team -= {"reshamas"} # in the comm team but not on the web page # ⑲ 剔除不展示的成员
这段代码定义了 get_contributors 函数,是整个脚本最复杂的部分。第⑤行用 zip 并行遍历 4 个团队 slug 与对应列表,对每个团队分两页拉取成员(GitHub API 默认每页 30 个)。第⑪行用集合推导式仅保留 login 字段。第⑫-⑭行的 |=、-= 是手动修正集操作:补录历史元老、剔除 CI 机器人。第⑯行用集合差集 emeritus = members - core_devs - ... 自动计算荣誉成员,这种分层互斥的数学性质保证了同一贡献者不会在多个分类中出现。第⑱行从荣誉成员中减去两个特殊集合,让硬编码的荣誉团队成员独立展示。
源码路径:build_tools/generate_authors_table.py - get_profile()(103-123行)
def get_profile(login): # ① 函数定义:输入 GitHub login
"""Get the GitHub profile from login"""
print("get profile for %s" % (login,)) # ② 打印正在处理的 login
try:
profile = get("https://api.github.com/users/%s" % login).json() # ③ 请求 GitHub /users/{login} 接口
except requests.exceptions.HTTPError: # ④ 捕获 HTTP 错误(如 404)
return dict(name=login, avatar_url=LOGO_URL, html_url="") # ⑤ 返回降级 profile:仅含 login 作为姓名
if profile["name"] is None: # ⑥ 若 GitHub 未设置真名则用 login 兜底
profile["name"] = profile["login"]
# fix missing names # ⑦ 硬编码修正表:某些特殊 login 对应的真名
missing_names = {
"bthirion": "Bertrand Thirion",
"dubourg": "Vincent Dubourg",
"Duchesnay": "Edouard Duchesnay",
"Lars": "Lars Buitinck",
"MechCoder": "Manoj Kumar",
}
if profile["name"] in missing_names: # ⑧ 若当前名字是修正表中的 key
profile["name"] = missing_names[profile["name"]] # ⑨ 替换为完整姓名
return profile # ⑩ 返回完整 profile 字典
这段代码定义了 get_profile 函数,调用 GitHub /users/{login} 接口获取真实姓名、头像与主页。它内置三道兜底机制:第一道是 HTTP 异常时返回仅含 login 的最小字典,第二道是 GitHub 未填姓名时用 login 替代,第三道是针对若干"历史遗留 login"用硬编码表补全真名。这种多层防御确保所有贡献者都至少有可显示的姓名。
源码路径:build_tools/generate_authors_table.py - key()(125-130行)
def key(profile): # ① 函数定义:输入 profile 字典
"""Get a sorting key based on the lower case last name, then firstname"""
components = profile["name"].lower().split(" ") # ② 全名小写并按空格分词为列表
return " ".join([components[-1]] + components[:-1]) # ③ 末位词作姓氏移到首位,其余作名字拼接
这段代码定义了 key 排序函数,按姓氏首字母、名字字母排序。它假设姓在名之后(西方命名习惯),将最后一个词作为姓氏移到最前,其余作为名字拼接。这样 Bertrand Thirion 变成 thirion bertrand,排序时按 t 开头的姓氏字母序,自然符合英文名册习惯。
源码路径:build_tools/generate_authors_table.py - generate_table()(132-146行)
def generate_table(contributors): # ① 函数定义:输入贡献者 profile 列表
lines = [
".. raw :: html\n", # ② RST raw 指令:让 Sphinx 跳过解析保留原样
" <!-- Generated by generate_authors_table.py -->", # ③ 注释标记,便于溯源
' <div class="sk-authors-container">', # ④ 自定义容器类(便于 CSS 控制)
" <style>", # ⑤ 内联样式块
" img.avatar {border-radius: 10px;}", # ⑥ 头像圆角样式
" </style>",
]
for contributor in contributors: # ⑦ 遍历每个贡献者
lines.append(" <div>") # ⑧ 单独容器
lines.append(
" <a href='%s'><img src='%s' class='avatar' /></a> <br />" # ⑨ 头像超链接
% (contributor["html_url"], contributor["avatar_url"])
)
lines.append(" <p>%s</p>" % (contributor["name"],)) # ⑩ 显示姓名段落
lines.append(" </div>") # ⑪ 关闭容器
lines.append(" </div>") # ⑫ 关闭外层容器
return "\n".join(lines) + "\n" # ⑬ 拼接为字符串并补尾随换行
这段代码定义了 generate_table 函数,生成包含 HTML/CSS 的 raw::html 指令。第②行用 .. raw:: html 让 Sphinx 不解析这段内容(保留原样),这样 HTML 标签不会被 RST 解析器转义。第⑥行的内联 CSS 让头像有圆角样式,第⑨-⑩行为每个贡献者生成头像链接和姓名段落。RST 表格无法显示圆形头像和复杂样式,因此核心团队成员必须用 HTML 表格。
源码路径:build_tools/generate_authors_table.py - generate_list()(148-153行)
def generate_list(contributors): # ① 函数定义:输入贡献者 profile 列表
lines = [] # ② 初始化行列表
for contributor in contributors: # ③ 遍历每个贡献者
lines.append("- %s" % (contributor["name"],)) # ④ 追加一行 RST 无序列表项
return "\n".join(lines) + "\n" # ⑤ 拼接为字符串并补尾随换行
这段代码定义了 generate_list 函数,生成纯 RST 列表。与 generate_table 相比,它仅输出 - 姓名 一行,不带头像与超链接,专供荣誉名册页面使用——荣誉名册信息密度大,无需视觉装饰。
源码路径:build_tools/generate_authors_table.py - __main__(155-193行)
if __name__ == "__main__":
( # ① 解构 7 个返回值(核心、荣誉、4 团队、2 荣誉团队)
core_devs,
emeritus,
contributor_experience_team,
emeritus_contributor_experience_team,
comm_team,
emeritus_comm_team,
documentation_team,
) = get_contributors()
print("Generating rst files") # ② 打印开始写文件的提示
with open( # ③ 核心开发者页面:带头像 HTML 表
REPO_FOLDER / "doc" / "maintainers.rst", "w+", encoding="utf-8"
) as rst_file:
rst_file.write(generate_table(core_devs))
with open( # ④ 核心荣誉页面:纯 RST 列表
REPO_FOLDER / "doc" / "maintainers_emeritus.rst", "w+", encoding="utf-8"
) as rst_file:
rst_file.write(generate_list(emeritus))
with open( # ⑤ 体验团队页面:带头像
REPO_FOLDER / "doc" / "contributor_experience_team.rst", "w+", encoding="utf-8"
) as rst_file:
rst_file.write(generate_table(contributor_experience_team))
with open( # ⑥ 体验团队荣誉页面:纯列表
REPO_FOLDER / "doc" / "contributor_experience_team_emeritus.rst",
"w+",
encoding="utf-8",
) as rst_file:
rst_file.write(generate_list(emeritus_contributor_experience_team))
with open( # ⑦ 沟通团队页面:带头像
REPO_FOLDER / "doc" / "communication_team.rst", "w+", encoding="utf-8"
) as rst_file:
rst_file.write(generate_table(comm_team))
with open( # ⑧ 沟通团队荣誉页面:纯列表
REPO_FOLDER / "doc" / "communication_team_emeritus.rst", "w+", encoding="utf-8"
) as rst_file:
rst_file.write(generate_list(emeritus_comm_team))
with open( # ⑨ 文档团队页面:带头像
REPO_FOLDER / "doc" / "documentation_team.rst", "w+", encoding="utf-8"
) as rst_file:
rst_file.write(generate_table(documentation_team))
这段代码是脚本入口与文件输出流程。它先调用 get_contributors() 解构 7 个团队/分类的 profile 列表,然后按"带头像/纯列表"两套模板分别写入 7 个 .rst 文件到 doc/ 目录。第③-⑨行的 7 个 with open 块一一对应 7 类贡献者页面,结构对称、职责清晰。
下面是贡献者名单生成的数据流:
94.8 xfail 测试标记有效性检查 —— 防止"标记失效"的质量闸门
scikit-learn 的测试套件用 xfail(expected failure)标记那些已知失败但在特定环境下无法修复的测试。比如某个测试在 Windows 平台失败,但 CI 不在 Windows 上跑,维护者就标 xfail 让它在其他平台上"假装失败"以避免误报。但环境变化时,原本失败的测试可能修好了,xfail 标记却没移除,导致"虚假通过"——XPASS。这会掩盖真实回归风险。
check_xfailed_checks.py 就是审计这些 xfail 标记的工具。脚本针对的核心痛点是:被 xfail 标记的测试有两种"静默失效"路径——一是测试本该失败却意外通过(标记失效),二是测试本应失败但运行错误信息被默认处理逻辑吞掉,无法暴露真实失败。它遍历所有被测试的估计器,调用 check_estimator 跑全套测试,对比"实际失败集合"与"预期失败集合",通过双向差集找出两类问题:意外失败(可能新引入 Bug)和预期未触发(xfail 已失效)。
检测原理分两步。第一步是遍历 _tested_estimators() 生成器,它产出所有被纳入公共测试套件的估计器类(如 LogisticRegression、RandomForestClassifier),是 check_estimator 的输入枚举源。第二步是调用 check_estimator(estimator, on_skip=None, on_fail=None),其中 on_skip 与 on_fail 两个参数是关键的禁用开关:默认情况下,check_estimator 把 xfail 命中的测试当 skip/fail 自动放过,相当于"假装测试已正确处理";传入 None 后,check_estimator 跳过这套默认处理逻辑,让所有异常情况都以 status="failed" 的形式返回,审计脚本才能拿到真实的裸测试结果用于差集校验。
工程细节方面,脚本使用 contextlib.redirect_stdout(io.StringIO()) 与 contextlib.redirect_stderr(io.StringIO()) 静默标准输出与标准错误——check_estimator 内部会打印大量检查进度与警告堆栈,淹没审计结果;通过上下文管理器重定向到内存缓冲区,让脚本只输出最终发现的异常。CI 集成方式上,该脚本通常作为定期任务(cron)运行,或在 PR 合入前由维护者本地执行;输出非空时阻断发布流程,要求贡献者更新 _get_expected_failed_checks 字典或修复回归。
源码路径:maint_tools/check_xfailed_checks.py - __main__(11-38行)
for estimator in _tested_estimators(): # ① 遍历所有被测试的估计器
# calling check_estimator w/o passing expected_failed_checks will find
# all the failing tests in your environment.
# suppress stdout/stderr while running checks
with (
contextlib.redirect_stdout(io.StringIO()), # ② 静默标准输出(屏蔽 check_estimator 内部 print)
contextlib.redirect_stderr(io.StringIO()), # ③ 静默标准错误(屏蔽警告与堆栈)
):
check_results = check_estimator(estimator, on_skip=None, on_fail=None) # ④ 禁用默认 skip/fail 处理,跑全套检查
failed_tests = [e for e in check_results if e["status"] == "failed"] # ⑤ 筛选 status="failed" 的真实失败
failed_test_names = set(e["check_name"] for e in failed_tests) # ⑥ 仅保留 check_name 字段并去重
expected_failed_tests = set(_get_expected_failed_checks(estimator).keys()) # ⑦ 从估计器元数据读取预期失败集合
unexpected_failures = failed_test_names - expected_failed_tests # ⑧ 意外失败 = 实际失败 - 预期失败
if unexpected_failures: # ⑨ 若非空则报警"可能新引入 Bug"
print(f"{estimator.__class__.__name__} failed with unexpected failures:")
for failure in unexpected_failures:
print(f" {failure}")
expected_but_not_raised = expected_failed_tests - failed_test_names # ⑩ 预期未触发 = 预期失败 - 实际失败
if expected_but_not_raised: # ⑪ 若非空则报警"xfail 已失效"
print(f"{estimator.__class__.__name__} did not fail expected failures:")
for failure in expected_but_not_raised:
print(f" {failure}")
这段代码是脚本主循环,对每个估计器执行"实际跑测试 → 对比预期"的审计。第④行的 on_skip=None, on_fail=None 是关键:禁用 check_estimator 默认的跳过/失败处理,让脚本能拿到真实的测试运行结果,而不是被默认处理逻辑掩盖后的输出。第⑧行和第⑩行是双向集合差集:A - B 表示"A 中有但 B 中没有",对应两类问题。
下面是双向校验的集合关系:
94.9 设计中的取舍
关于 bump-dependencies-versions.py 为何不直接用 pip index versions 或 pip install --dry-run:根本原因在于 pip 不会告诉你某个 wheel 是否对应特定 Python 版本,也不会告诉你该版本是否已 yanked(撤回)。PyPI JSON API 才是唯一权威源,能直接拿到 packagetype、yanked、upload_time 等结构化字段。脚本选择直接读 API 而非包一层 pip 抽象,是因为它要的不仅是"哪些版本可用",更是"哪些版本能用且适合作为最低依赖"——这需要解析文件级元数据,而 pip 抽象把这一层信息屏蔽掉了。
关于 sort_whats_new.py 为何用 stdin 而不是读文件:因为变更日志条目在发布周期中是渐进式追加的,维护者会从 PR 描述、GitHub release 草稿、issue 评论中逐步累积条目。用 stdin 管道让维护者可以"边收集边预览":从任意文本编辑器复制粘贴到终端,或从 gh pr list 命令的输出中提取。这种 Unix 风格的"做一件事并做好"哲学让工具与其他维护脚本天然组合,避免硬编码文件路径。同时,由于发布周期短、条目格式相对固定(- |标签| 描述),无需持久化文件与断点续传,stdin 简化了脚本设计。
关于 autoclose_prs.py 的 dry_run 硬编码为 False 而不是 True:因为这是一个主动操作而非查询脚本。CI 工作流(.github/workflows/autoclose-schedule.yml)在调用时已经过人工审核,仓库管理员有权决定何时真正执行关闭。如果默认开启 dry-run,反而要求每次操作都人工覆盖回 False,降低自动化价值。设计上信任调用方的同时,通过 pprint 输出提供完整的审计日志,让误关情况可被快速发现并人工恢复。值得补充的是,干运行模式实际由 CI 工作流参数控制——典型做法是在 .github/workflows/autoclose-schedule.yml 中定义 workflow_dispatch 触发器,运维人员可手动触发一个 dry_run=true 的预演 job 查看待关闭列表而不真正执行;定时 cron job 则使用默认的实运行模式。
关于 generate_authors_table.py 用 set 而不是 list 处理成员:因为 GitHub 组织成员可能在多个团队中出现(比如某人既是核心开发者又是沟通团队成员)。用 set 的天然去重特性避免重复展示,同时集合差集操作 emeritus = members - core_devs - ... 优雅地实现分层互斥。手动修正集(如 members |= {...})也只在 set 上才有 O(1) 的添加/删除效率。
关于 check_xfailed_checks.py 禁用 on_skip 和 on_fail:因为默认行为会把 skip 和 fail 当作"已知问题"自动放过,掩盖真实的测试状态。审计脚本需要的是裸的测试结果——哪些测试真的失败了,哪些测试意外通过。禁用处理函数后,check_estimator 会把所有异常情况以 status="failed" 的形式返回,让双向差集校验成为可能。如果不禁用,预期失败列表将永远为空(因为都被默认处理函数吞掉了),双向校验就失去意义。
94.10 动手练习
-
依赖版本策略模拟与对比
-
阅读
maint_tools/bump-dependencies-versions.py中三个版本获取函数的实现差异。 -
回答问题:
-
为什么编译型依赖(如 NumPy)使用
min()选择最老版本,而纯 Python 依赖(如 joblib)使用max()选择最新版本? -
get_min_version_with_wheel中为何过滤packagetype == 'bdist_wheel'且检查文件名包含cp{python_version}? -
若某依赖在 PyPI 上无任何符合条件的 Wheel,函数返回
None,上层show_versions_update会如何处理?
-
-
-
What's New 条目解析与排序实验
-
构造一段包含多种标签、跨模块引用、无引用条目的模拟输入文本,通过管道传给
sort_whats_new.py。 -
观察输出结果,回答问题:
-
正则
:(?:func|meth|mod|class):\(?:[^<`]*<|~)?(?:sklearn.)?([a-z]\w+)` 如何匹配 `:func:`sklearn.linear_model.LogisticRegression`` 和 `:mod:`~sklearn.ensemble``? -
条目同时引用
sklearn.linear_model和sklearn.preprocessing时,会被归入哪个桶?桶名是什么? -
无任何 Sphinx 引用的条目(如
- |Fix| 修复了文档拼写错误)会被归入哪个桶?其排序键entry_sort_key返回值为何?
-
-
-
PR 自动关闭机制的时间窗口边界分析
-
阅读
build_tools/github/autoclose_prs.py中get_labeled_last_time与主流程的时间比较逻辑。 -
回答问题:
-
若 PR 被多次添加/移除/再添加
autoclose标签,脚本以哪次时间为准?为何这样设计? -
cutoff_days = 14硬编码在脚本中,若策略调整为 21 天,需修改哪里?能否通过环境变量配置? -
dry_run = False硬编码,但注释建议 CI 先跑 dry-run。为何不设为True默认安全?实际 CI 工作流.github/workflows/autoclose-schedule.yml可能如何传参覆盖?
-
-
-
贡献者名单生成的集合运算与去重逻辑
-
分析
build_tools/generate_authors_table.py中get_contributors的集合运算部分。 -
回答问题:
-
emeritus = members - core_devs - contributor_experience_team - comm_team - documentation_team为何能保证分层互斥? -
contributor_experience_team -= core_devs这行代码的作用?为何不在emeritus计算前执行? -
手动修正集
members |= {"Angel Soler Gollonet"}添加的是无 GitHub 账号贡献者,后续get_profile会如何处理该字符串?会报错吗? -
generate_table输出.. raw:: html指令,Sphinx 构建时如何保证 HTML 不被转义?为何不统一用 RST 表格?
-
-
-
xfail 测试标记审计的双向集合差集验证
-
阅读
maint_tools/check_xfailed_checks.py主循环中的集合运算逻辑。 -
回答问题:
-
check_estimator(estimator, on_skip=None, on_fail=None)中禁用on_skip/on_fail处理的目的是什么?若不禁用会怎样? -
unexpected_failures = failed_test_names - expected_failed_tests非空意味着什么?此时应采取什么行动? -
expected_but_not_raised = expected_failed_tests - failed_test_names非空意味着什么?为何这比"测试通过"更危险? -
脚本使用
contextlib.redirect_stdout/stderr静默输出,若需调试某估计器的具体失败堆栈,应如何修改?
-
-
94.11 本章小结
这一章我们走进了 scikit-learn 维护工具链的幕后,认识了一支由脚本组成的"管家团队"。首先我们学习了依赖版本升级策略,理解了 Python 版本"3 年规则"、编译型依赖"最老兼容 Wheel"、纯 Python 依赖"2 年规则"三种差异化策略背后的工程权衡;接着分析了 What's New 条目自动分类与排序机制,掌握了基于 Sphinx 角色正则的模块分桶与基于 LABEL_ORDER 的标签优先级排序;然后剖析了基于标签时间戳的过期 PR 自动关闭机制,理解了 get_labeled_last_time 如何精准定位最近一次贴标签时间,以及干运行模式如何提供安全演练;之后我们研究了贡献者名单自动生成器,学习了 GitHub API 团队成员拉取、集合运算分层、手动修正集、HTML 与 RST 双模板输出;最后我们解构了 xfail 测试标记有效性检查工具,理解了通过 on_skip=None, on_fail=None 禁用默认处理、双向集合差集校验的核心设计,以及它如何作为测试质量防线的最后一道闸门。
下面用表格汇总本章涉及的核心概念与对应的源码函数:
本章我们一起学习了以下概念:
| 概念 | 解释 |
|------|------|
| get_min_python_version | 依据 Python 官方发布历史,执行"发布超 3 年"规则计算最低支持 Python 版本 |
| get_min_version_with_wheel | 查询 PyPI,寻找提供当前最低 Python 版本 Wheel 的最早 X.Y.0 版本,避免用户源码编译 |
| get_min_version_pure_python_or_example_dependency | 针对纯 Python 依赖,选取发布超 2 年的最新 X.Y.0 版本,优先享受新特性 |
| get_current_dependencies_version | 通过子进程调用 sklearn/_min_dependencies.py 读取当前锁定的依赖版本字符串 |
| get_current_min_python_version | 用正则从 pyproject.toml 提取 requires-python 字段的版本号 |
| show_versions_update | 汇总所有策略计算的未来版本,与 pyproject.toml 及 _min_dependencies.py 当前版本对比,仅输出差异项 |
| entry_sort_key / LABEL_ORDER | 定义 What's New 条目标签优先级:MajorFeature > Feature > Efficiency > Enhancement > Fix > API |
| 模块分桶正则 | 从 Sphinx 交叉引用 :role:~sklearn.module.name` 中提取顶层模块名实现自动归类 |
| get_labeled_last_time | 遍历 PR 事件时间线,精准定位最近一次添加 autoclose 标签的时间戳 |
| autoclose 策略 | 标签存在 > 14 天 -> 自动评论关闭 PR;干运行模式支持安全预演 |
| get_contributors 分层逻辑 | 集合运算实现:核心开发者 > 团队成员(互斥)> 荣誉成员;手动修正集修补特例 |
| generate_table / generate_list | 双模板输出:HTML 表格含头像(核心/团队页)与纯 RST 列表(荣誉名册页) |
| check_xfailed_checks 双向校验 | 意外失败 = 实际失败 - 预期失败(报警新 Bug);预期未触发 = 预期失败 - 实际失败(报警 xfail 失效) |
| on_skip=None, on_fail=None | 禁用 check_estimator 默认的跳过/失败处理,获取原始测试执行结果用于审计 |
感谢你读到了这里,恭喜你,你已经完成了 scikit-learn 源码解析第 94 章。本章是本套书籍的最后一章,我们一起走过了从项目入门到深度算法、从基础设施到测试体系、从工具链到发布流水线的完整旅程。希望这段旅程让你对 scikit-learn 的工程之美有了更深的体会。

浙公网安备 33010602011771号