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 的繁琐配置。

graph TD A[CI 调用 vendor.py] --> B[校验 DLL 源文件] B --> C[校验 wheel 目录] C --> D[拼接目标路径] D --> E[创建 sklearn/.libs] E --> F[copy2 vcomp140.dll] E --> G[copy2 msvcp140.dll] F --> H[生成 _distributor_init.py] G --> H H --> I[wheel 安装后 ctypes 预加载]

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 根本没装?

graph TD A[脚本启动] --> B[site.getsitepackages 获取目录列表] B --> C[chain 跨目录 glob 查找 dist-info] C --> D{找到?} D -->|否| E[❌ RuntimeError] D -->|是| F[读取 licenses/COPYING] F --> G{含 Copyright c?} G -->|否| H[❌ AssertionError] G -->|是| I{含平台特定许可证声明?} I -->|否| H I -->|是| J[✅ 通过]

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 时,stableversion_num 会被先记入 seen,等到真正遍历到目录 1.5 时就被跳过。这种"先收数据、再做指针重定向、最后去重输出"的三段式设计让版本墙既不重复也不漏列。作为辅助的 human_readable_data_quantityget_file_extension 则像量尺与标签打印机:量尺按 SI/IEC 单位制灵活切换,标签打印机根据版本号自动选择 zip/pdf 格式——这两位"小助手"被 get_file_size__main__ 反复调用,贯穿整个版本墙构建流水线。

graph TD A[list_versions.py 启动] --> B[请求 GitHub API 根目录] B --> C{路径类型?} C -->|普通目录| D[抓 index.html 提取版本号] C -->|符号链接| E[记录 target] D --> F[请求 _downloads 归档大小] F --> G[合并 dirs 与 symlinks] E --> G G --> H[按 dev → stable → 版本倒序遍历] H --> I{i < 8?} I -->|是| J[加入 JSON 切换器] I -->|否| K[跳过] J --> L[生成 RST 行] K --> L L --> M[写出 RST 与 JSON]

93.14 设计中的取舍

为什么 PR 标签选择 POST /labels 而非 issue 级 PATCH 这是 GitHub REST API 的设计哲学:POST /repos/{owner}/{repo}/issues/{issue_number}/labelsissue.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 下拉菜单的可用性约束:过多版本会让菜单滚动过长,降低导航效率;同时保留 devstable 占据 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.pyn_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.pydev 强制返回 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 动手练习

  1. 阅读 PR 标题标签自动化脚本

    • 阅读 .github/scripts/label_title_regex.py,回答问题:

      1. 脚本如何从 GitHub Actions 环境中获取 PR 标题和编号?

      2. 正则映射表中 ENHBUGDOC 等前缀分别对应哪些标签?

      3. 为什么要使用 POST 而非 PATCH 调用 Labels API?幂等性如何保证?

  2. 分析 Azure 提交消息提取与清洗机制

    • 阅读 build_tools/azure/get_commit_message.py,回答问题:

      1. 脚本按什么优先级尝试不同的环境变量获取提交信息?

      2. 脚本防范的是哪种攻击?此清洗策略的优势是什么?

      3. 如果环境变量与 git log 结果不一致,脚本会如何处理?

  3. 理解 Lint 报告机器人评论生成

    • 阅读 build_tools/get_comment.py,回答问题:

      1. 脚本如何按工具分组生成结构化报告?

      2. ❌ Linting issues 标题在评论幂等更新中起什么作用?

      3. 如何通过 GitHub API 实现"找到旧评论则更新,否则创建"的逻辑?

  4. 探究 OpenMP 依赖一致性校验

    • 阅读 build_tools/check-meson-openmp-dependencies.py,回答问题:

      1. git grepmeson introspect 分别产出什么数据结构?如何交叉比对?

      2. 为什么源文件集合必须是 Meson 目标集合的子集?反向冗余为什么也被整体 raise 抛出?

      3. 如果新增了一个使用 OpenMP 的 .pyx 文件但忘记更新 meson.build,CI 会在哪个阶段报错?

  5. 实验随机种子测试动态选择

    • 阅读 build_tools/azure/get_selected_tests.py,回答问题:

      1. 脚本如何复用 get_commit_message.py 的输出?

      2. [all random seeds] 标记后的多行测试名是如何被转换成 pytest -k 可消费的表达式的?

      3. 输出 JSON 中的 selected_tests 字段在下游 pytest 调用中如何被消费?

  6. 剖析 CI 失败与 GitHub Issue 联动

    • 阅读 maint_tools/update_tracking_issue.py,回答问题:

      1. JUnit XML 中 <testcase> 的哪些子元素包含堆栈信息?如何聚合同名测试的多维度失败?

      2. Issue 标题中的幂等键包含哪些维度?为何不包含构建编号?

      3. PATCH /issues/comments/{comment_id}POST /issues 在权限与速率限制上有何差异?

  7. 阅读构建产物校验脚本

    • 阅读 build_tools/github/check_wheels.py,回答问题:

      1. 脚本如何解析 .github/workflows/wheels.yml 获取预期构建数量?

      2. 为什么实际产物数量要比矩阵项数多 1(n_wheels += 1)?

      3. 如果数量不匹配,脚本如何阻断后续发布流程?

  8. 分析 Windows DLL 嵌入机制

    • 阅读 build_tools/github/vendor.py,回答问题:

      1. make_distributor_init_64_bits 生成的代码为何使用 op.abspath 而非相对路径加载 DLL?

      2. _distributor_init.py 为何能比其他 Cython 扩展模块更早执行?

      3. shutil.copy2 相比 shutil.copy 在复制 DLL 时有何优势?

      4. __main__ 入口如何接收并传递 wheel_dirname 参数?

  9. 理解许可证合规检查

    • 阅读 build_tools/wheels/check_license.py,回答问题:

      1. 脚本如何定位安装后的 scikit_learn-*.dist-info 目录?

      2. 断言检查的两个关键字符串分别对应什么法律要求?

      3. 为何使用 platform.system() 区分平台相关的打包软件声明?

  10. 探究文档版本管理脚本

    • 阅读 build_tools/circle/list_versions.py,回答问题:

      1. get_file_extension 函数为何在版本含 'dev' 时强制返回 'zip'?

      2. 版本排序为何使用 sklearn.utils.fixes.parse_version 而非标准库 packaging.version

      3. 符号链接(如 stable -> 1.5)的元数据复用与去重逻辑是如何实现的?

      4. 版本切换器 JSON 为何仅保留最新 8 个版本?

      5. json_urlread 如何处理网络异常?为何打印到 stderr 而非 raise?

      6. human_readable_data_quantitymultiple=10241000 的区别是什么?文档归档大小用哪个?

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_versionget_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",代表当前日期。这种"参数可选"的设计让脚本既能用于当下决策,也能用于未来发布的预演,体现了工具的灵活性。

整体策略可以用下面的流程图概括:

graph TD A[__main__ 入口<br/>解析发布日期参数] --> B[show_versions_update 汇总] B --> C[计算 Python 最低版本<br/>get_min_python_version] C --> D[遍历编译型依赖列表] D --> E[get_min_version_with_wheel<br/>查询 PyPI 最老兼容 Wheel] E --> F[遍历纯 Python 依赖列表] F --> G[get_min_version_pure_python_or_example_dependency<br/>查询 PyPI 最新 2 年前版本] G --> H[get_current_min_python_version 读取 pyproject.toml] H --> I[get_current_dependencies_version 逐个调用 _min_dependencies.py] I --> J[逐项对比] J --> K{有差异?} K -->|是| L[打印升级建议] K -->|否| M[跳过]

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_modelensemble),按模块名分桶后用 LABEL_ORDER 列表定义 MajorFeature > Feature > Efficiency > Enhancement > Fix > API 的优先级在桶内排序。多模块/无模块处理逻辑也很巧妙:若一条目同时引用了多个 sklearn 子模块(如 linear_modelpreprocessing),它会被归入 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 modulesMiscellaneous 按字母排到合适位置;第⑮行在桶内用 entry_sort_key 完成标签优先级排序。整个流程不依赖任何网络请求,纯字符串处理,毫秒级完成。

下面是分桶与排序的流程图:

graph TD A[stdin 读取原始条目] --> B[按空行分割为单条] B --> C{遍历每条} C --> D[正则匹配 Sphinx 角色引用] D --> E{模块数量} E -->|>1| F[桶 = Multiple modules] E -->|=1| G[桶 = :mod:`sklearn.xxx`] E -->|=0| H[桶 = Miscellaneous] F --> I[defaultdict 收集] G --> I H --> I I --> J{还有条目?} J -->|是| C J -->|否| K[按桶名排序] K --> L[桶内按 entry_sort_key 排序] L --> M[生成 RST 标题 + 条目列表] M --> N[print 输出]

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 自动关闭的状态机:

graph TD A[CI 定时任务触发] --> B[查询所有带 autoclose 标签的 PR] B --> C{是 PR 还是 Issue?} C -->|Issue| D[跳过] C -->|PR| E[遍历 PR 事件] E --> F[定位最近一次贴 autoclose 时间] F --> G{now - 时间 > 14 天?} G -->|否| H[跳过] G -->|是| I[dry_run = True?] I -->|是| J[仅打印列表] I -->|否| K[发表评论] K --> L[关闭 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 类贡献者页面,结构对称、职责清晰。

下面是贡献者名单生成的数据流:

graph TD A[GitHub API /orgs/scikit-learn/teams/.../members] --> B[4 个团队成员列表] C[GitHub API /orgs/scikit-learn/members] --> D[全体成员列表] B --> E[集合化 login] D --> E E --> F[手动修正集<br/>补录/剔除] F --> G[emeritus 计算<br/>差集分层] G --> H[get_profile 拉取头像/姓名] H --> I[key 函数按姓氏排序] I --> J[generate_table HTML 版] I --> K[generate_list RST 版] J --> L[写入 doc/*.rst] K --> L

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() 生成器,它产出所有被纳入公共测试套件的估计器类(如 LogisticRegressionRandomForestClassifier),是 check_estimator 的输入枚举源。第二步是调用 check_estimator(estimator, on_skip=None, on_fail=None),其中 on_skipon_fail 两个参数是关键的禁用开关:默认情况下,check_estimatorxfail 命中的测试当 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 中没有",对应两类问题。

下面是双向校验的集合关系:

graph LR A[failed_test_names<br/>实际失败集合] --- B[expected_failed_tests<br/>预期失败集合] A -->|A - B| C[unexpected_failures<br/>意外失败 = 可能新 Bug] B -->|B - A| D[expected_but_not_raised<br/>预期未触发 = xfail 失效] style C fill:#f9c,stroke:#333 style D fill:#fc9,stroke:#333

94.9 设计中的取舍

关于 bump-dependencies-versions.py 为何不直接用 pip index versionspip install --dry-run:根本原因在于 pip 不会告诉你某个 wheel 是否对应特定 Python 版本,也不会告诉你该版本是否已 yanked(撤回)。PyPI JSON API 才是唯一权威源,能直接拿到 packagetypeyankedupload_time 等结构化字段。脚本选择直接读 API 而非包一层 pip 抽象,是因为它要的不仅是"哪些版本可用",更是"哪些版本能用且适合作为最低依赖"——这需要解析文件级元数据,而 pip 抽象把这一层信息屏蔽掉了。

关于 sort_whats_new.py 为何用 stdin 而不是读文件:因为变更日志条目在发布周期中是渐进式追加的,维护者会从 PR 描述、GitHub release 草稿、issue 评论中逐步累积条目。用 stdin 管道让维护者可以"边收集边预览":从任意文本编辑器复制粘贴到终端,或从 gh pr list 命令的输出中提取。这种 Unix 风格的"做一件事并做好"哲学让工具与其他维护脚本天然组合,避免硬编码文件路径。同时,由于发布周期短、条目格式相对固定(- |标签| 描述),无需持久化文件与断点续传,stdin 简化了脚本设计。

关于 autoclose_prs.pydry_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.pyset 而不是 list 处理成员:因为 GitHub 组织成员可能在多个团队中出现(比如某人既是核心开发者又是沟通团队成员)。用 set 的天然去重特性避免重复展示,同时集合差集操作 emeritus = members - core_devs - ... 优雅地实现分层互斥。手动修正集(如 members |= {...})也只在 set 上才有 O(1) 的添加/删除效率。

关于 check_xfailed_checks.py 禁用 on_skipon_fail:因为默认行为会把 skip 和 fail 当作"已知问题"自动放过,掩盖真实的测试状态。审计脚本需要的是裸的测试结果——哪些测试真的失败了,哪些测试意外通过。禁用处理函数后,check_estimator 会把所有异常情况以 status="failed" 的形式返回,让双向差集校验成为可能。如果不禁用,预期失败列表将永远为空(因为都被默认处理函数吞掉了),双向校验就失去意义。

94.10 动手练习

  1. 依赖版本策略模拟与对比

    • 阅读 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 会如何处理?

  2. 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_modelsklearn.preprocessing 时,会被归入哪个桶?桶名是什么?

      • 无任何 Sphinx 引用的条目(如 - |Fix| 修复了文档拼写错误)会被归入哪个桶?其排序键 entry_sort_key 返回值为何?

  3. PR 自动关闭机制的时间窗口边界分析

    • 阅读 build_tools/github/autoclose_prs.pyget_labeled_last_time 与主流程的时间比较逻辑。

    • 回答问题:

      • 若 PR 被多次添加/移除/再添加 autoclose 标签,脚本以哪次时间为准?为何这样设计?

      • cutoff_days = 14 硬编码在脚本中,若策略调整为 21 天,需修改哪里?能否通过环境变量配置?

      • dry_run = False 硬编码,但注释建议 CI 先跑 dry-run。为何不设为 True 默认安全?实际 CI 工作流 .github/workflows/autoclose-schedule.yml 可能如何传参覆盖?

  4. 贡献者名单生成的集合运算与去重逻辑

    • 分析 build_tools/generate_authors_table.pyget_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 表格?

  5. 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 的工程之美有了更深的体会。

posted @ 2026-09-04 04:09  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报