400-100-5265

预约演示

RapidOCR 打包迁移实践

2026-06-21

Python 项目打包这件事,很多时候只有项目维护者自己觉得“还能用”。CI 能跑、PyPI 能发、用户能安装,看起来问题不大。但一旦下游用户需要自己构建 whl 包,或者项目结构稍微复杂一点,原来那些藏在脚本、CI、目录约定里的隐性逻辑,就会一起冒出来。

RapidOCR 之前一直使用 setup.py setuptools 打包。这个方案并不罕见,也不是不能用。真正的问题在于:它逐渐把版本号生成、目录适配、资源准备、包构建这些本该分层处理的事情揉在了一起。维护者熟悉流程时感觉还好,外部用户接手时就变成了“照着 CI 猜构建步骤”。

这次迁移到 pyproject.toml,表面看是一次打包工具链调整,实际解决的是 Python 项目发布流程的工程可维护性问题。

一、setup.py 能跑,但复杂度被藏起来了

RapidOCR 自从提供 whl 包以来,一直使用 setuptools 打包。早期最关注的一个硬性需求是:版本号必须自动化。

对于开源库来说,版本号自动化很重要。手动改版本号看起来简单,但时间一长很容易出问题:

  • tag 是 v3.1.0,包里却还是 3.0.9
  • 本地构建和 CI 构建版本不一致
  • 发布流程依赖维护者记忆
  • 下游自行构建时不知道版本从哪里来

为了解决这个问题,RapidOCR 曾经使用过自定义工具 GetPyPiLatestVersion,用于获取指定库在 PyPI 上的最新版本。后来发现 GitHub Actions 在打 tag 时可以直接拿到 tag 名称,于是版本号可以在 CI 中传给 setup.py

典型流程大致是这样:

name: Push rapidocr to pypi

on:
  push:
    tags:
      - v*

jobs:
  TestAndPublish:
    runs-on: ubuntu-latest
    steps:
      - name: Build wheel package
        run: |
          cd python
          python setup.py bdist_wheel ${{ github.ref_name }}
          mv dist ../

这个方案在维护者视角下是顺的:打 tag,触发 CI,CI 取 tag,传给 setup.py,然后构建 whl。

问题也在这里。它把关键构建逻辑放到了 CI 里,而不是项目本身。

CI 当然可以承载发布流程,但包如何构建,最好应该由项目元数据和标准构建入口描述清楚。否则下游用户想自己构建时,就需要去翻 GitHub Actions,然后从里面拼出一套可执行步骤。

很多团队做到这里会卡住:内部流程越跑越熟,外部可复现性却越来越差。工具链本身没坏,是边界划错了。

二、下游构建暴露了真实问题

这次迁移的直接触发点,是社区伙伴 @vshawrh 提的两个问题:#667 和 #685。

它们暴露出一个长期被掩盖的缺陷:当前模式主要服务于 RapidOCR 官方发布流程,并没有充分考虑下游用户自行构建 whl 包。

对于下游用户来说,他们关心的不是“官方 CI 是怎么发布的”,而是:

git clone ...
cd ...
python -m build --wheel

这类标准动作能不能工作。

如果不行,就会产生几个现实问题。

第一,构建步骤不可发现。

用户需要知道 CI 里传了什么参数、进入了哪个目录、构建前是否需要准备模型资源、是否要移动产物。这些都不是 Python 打包系统天然能表达的信息,而是散落在脚本和 CI 配置中的约定。

第二,setup.py 逻辑越来越厚。

setup.py 最早只是构建入口,但在很多老项目里,它会慢慢承担越来越多职责,比如:

  • 动态计算版本号
  • 修改导入路径
  • 读取依赖文件
  • 注入额外资源
  • 处理不同构建场景
  • 兼容本地构建和 CI 构建

写到最后,setup.py 就变成了一个“构建控制器”。它能用,但调试成本高,也不利于标准化。

第三,项目结构为了打包反过来妥协。

RapidOCR 之前还遇到过需要将 rapidocr 目录包裹一层,才能正确导入的问题。这类问题很典型:代码组织本应服务于工程结构和开发体验,但如果打包逻辑处理不好,就会反过来要求目录结构让步。

这种让步短期能解决问题,长期会让项目越来越别扭。

三、pyproject.toml 的价值在于边界清晰

迁移到 pyproject.toml,并不只是换一个配置文件。它的核心价值是把构建系统、项目元数据、包发现规则、动态版本策略这些内容,用标准方式写清楚。

RapidOCR 迁移后的核心配置如下:

[build-system]
requires = [
    "setuptools>=77",
    "wheel",
    "setuptools-scm>=8",
]
build-backend = "setuptools.build_meta"

[project]
name = "rapidocr"
dynamic = ["version", "dependencies"]
description = "Awesome OCR Library"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.8"
license = "Apache-2.0"
authors = [
    { name = "SWHL", email = "liekkaskono@163.com" },
]
keywords = [
    "ocr",
    "text_detection",
    "text_recognition",
    "db",
    "onnxruntime",
    "paddleocr",
    "openvino",
    "rapidocr",
]
classifiers = [
    "Programming Language :: Python :: 3.8",
    "Programming Language :: Python :: 3.9",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
    "Programming Language :: Python :: 3.13",
]

[project.urls]
Documentation = "https://rapidai.github.io/RapidOCRDocs"
Changelog = "https://github.com/RapidAI/RapidOCR/releases"

[project.scripts]
rapidocr = "rapidocr.main:main"

[tool.setuptools]
include-package-data = true
platforms = ["Any"]

[tool.setuptools.dynamic]
dependencies = { file = ["requirements.txt"] }

[tool.setuptools.packages.find]
where = ["."]
include = ["rapidocr*"]
exclude = ["tests*"]
namespaces = false

[tool.setuptools.package-data]
rapidocr = [
    "**/*.yaml",
]

[tool.setuptools_scm]
root = ".."
tag_regex = "^v?(?P.*)$"
version_file = "rapidocr/_version.py"
local_scheme = "no-local-version"

这段配置里,有几个关键点值得看。

四、版本号交给 setuptools-scm

RapidOCR 之前最核心的需求是自动化版本号。迁移后,这个问题由 setuptools-scm 解决。

它的思路很直接:从 Git 元数据中推导版本号。常见情况下,tag 就是版本来源。

[tool.setuptools_scm]
root = ".."
tag_regex = "^v?(?P.*)$"
version_file = "rapidocr/_version.py"
local_scheme = "no-local-version"

这里有几个细节。

root = ".." 表示 Git 仓库根目录在当前 Python 包目录的上一级。这适合类似 RapidOCR 这种仓库结构:项目根目录下有 python 子目录,而 Python 包在子目录内。

tag_regex = "^v?(?P.*)$" 用来兼容 v3.1.03.1.0 这类 tag 格式。很多项目喜欢在 tag 前加 v,但 Python 包版本号通常不需要这个前缀。

version_file = "rapidocr/_version.py" 会把推导出的版本写入包内文件。这样运行时也可以稳定读取版本,不必每次都依赖 Git 环境。

local_scheme = "no-local-version" 则会避免构建出带本地版本后缀的包名。对于正式发布到 PyPI 的包来说,这通常更干净。

这个方案的工程收益很明确:版本号规则回到项目配置里,而不是散落在 CI 参数和 setup.py 逻辑中。

当然,它也有边界。setuptools-scm 依赖 Git 元数据。如果用户拿到的是不包含 .git 信息的源码快照,就需要额外指定版本号。RapidOCR 在自定义构建文档里通过环境变量处理了这个问题:

SETUPTOOLS_SCM_PRETEND_VERSION_FOR_RAPIDOCR=3.1.0 python -m build --wheel

这就是一个合理的 trade-off:正常开发和发布场景使用 Git tag 自动推导版本;脱离 Git 环境的构建场景,允许用户显式注入版本号。规则统一,入口也清楚。

五、把资源准备从打包逻辑里拆出去

RapidOCR 这类 OCR 项目和普通纯 Python 工具库不太一样,它可能涉及模型文件、配置文件等打包资源。

如果把这些资源准备逻辑塞进 setup.py,会出现一个很麻烦的问题:构建包这一步会变得不可预测。

理想的构建动作应该尽量纯粹:

python -m build --wheel

它负责把当前目录下已经准备好的内容打成包,而不是顺手下载模型、改目录、生成资源、调整环境。

RapidOCR 迁移后,把下载打包必需模型这一步分离出来:

python tools/prepare_wheel_assets.py

然后再执行构建:

python -m build --wheel

这个拆分很重要。

构建前准备资源和构建 whl 是两个不同阶段。前者可能依赖网络、缓存、模型版本、外部资源;后者应该尽量依赖本地文件系统和标准构建工具。

把它们混在一起,短期看命令少一步,长期看会增加不确定性。尤其是 CI、离线环境、内网镜像、自定义模型构建这些场景,资源准备阶段越透明,越容易定位问题。

RapidOCR 的资源声明也回到了 pyproject.toml

[tool.setuptools.package-data]
rapidocr = [
    "**/*.yaml",
]

这里明确告诉打包系统:rapidocr 包内的 YAML 文件需要被包含进去。

资源文件打包一直是 Python 项目里容易踩坑的点。代码能 import,不代表配置文件能随包分发;本地能跑,也不代表安装后的 site-packages 里文件还在。把 package data 显式写出来,比靠目录习惯和隐式包含稳得多。

六、依赖和入口也应该标准化

迁移后的配置里,依赖使用动态读取:

[project]
dynamic = ["version", "dependencies"]

[tool.setuptools.dynamic]
dependencies = { file = ["requirements.txt"] }

这说明依赖仍然维护在 requirements.txt 中,但由 setuptools 在构建时读取并注入项目元数据。

这是一种折中方案。

更彻底的做法是把依赖直接写进 pyproject.toml[project.dependencies],这样元数据更集中。但如果项目历史上已经围绕 requirements.txt 建立了开发、测试、CI 流程,直接迁移可能会带来额外改动。

这里选择动态读取,属于比较现实的工程取舍:先把构建入口标准化,降低迁移风险,再视情况逐步收敛依赖管理。

命令行入口也通过标准字段声明:

[project.scripts]
rapidocr = "rapidocr.main:main"

这比在 setup.py 里写 entry_points 更直观,也更符合现代 Python 打包规范。用户安装后可以直接使用:

rapidocr

工具入口、包元数据、资源规则都在一个配置文件里,维护成本会明显低一些。

七、下游构建流程变简单了

迁移后,下游用户自行构建指定版本 RapidOCR whl 包,大致只需要几步:

git clone https://github.com/RapidAI/RapidOCR.git
cd RapidOCR/python

python -m pip install --upgrade pip
python -m pip install build setuptools wheel setuptools-scm PyYAML

python tools/prepare_wheel_assets.py

SETUPTOOLS_SCM_PRETEND_VERSION_FOR_RAPIDOCR=3.1.0 python -m build --wheel

这个流程比之前更接近标准 Python 项目的构建体验。

可以把变化简单对比一下:

维度 setup.py 模式 pyproject.toml 模式
版本号 依赖 CI 传参或自定义逻辑 setuptools-scm 从 tag/Git 元数据推导
构建入口 python setup.py bdist_wheel python -m build --wheel
元数据 分散在脚本逻辑中 集中在 pyproject.toml
资源准备 容易耦合进打包过程 独立脚本显式执行
下游构建 需要参考 CI 细节 文档化标准步骤
可维护性 随项目增长变复杂 边界更清晰

这里并不是说 setup.py 一无是处。很多老项目继续使用它也没问题,尤其是内部项目或构建逻辑简单的库。

但对于 RapidOCR 这种有下游用户、有模型资源、有多平台构建需求的开源项目,继续让 setup.py 承担过多职责,后续只会越来越重。迁移到 pyproject.toml 的收益不在“新”,而在“把复杂度放到正确的位置”。

八、标准化不是为了好看

Python 打包生态这些年一直在往 PEP 517、PEP 518、PEP 621 这套方向收敛。pyproject.toml 成了越来越多工具的共同入口。

这背后的逻辑并不复杂:构建系统需要一个标准协议,项目元数据需要一个可被工具读取的声明位置,构建前端和构建后端需要解耦。

用 RapidOCR 的场景来说:

流程图 - RapidOCR 打包迁移实践

这条链路比之前清楚很多。

版本号怎么来,资源怎么准备,包怎么发现,哪些文件要带进去,命令行入口在哪里,都能找到明确位置。出了问题也更容易定位:

  • 版本号不对,看 setuptools-scm
  • 资源缺失,看 package-data 和资源准备脚本
  • 包没被包含,看 packages.find
  • 依赖没写入,看 tool.setuptools.dynamic
  • 构建失败,看 PEP 517 构建入口和 build backend

真正到生产环境和下游分发场景里,麻烦往往不在“能不能打出一个包”,而在“别人能不能按文档稳定打出同样的包”。

九、这次迁移的工程启发

RapidOCR 这次从 setup.py 迁移到 pyproject.toml,解决了几个具体问题:

  • setuptools-scm 替代自定义版本号传递逻辑
  • 将构建元数据集中到标准配置文件
  • 分离模型资源准备和 whl 构建
  • 简化包发现与导入结构
  • 降低下游用户自行构建成本
  • 让构建流程更容易文档化和复现

如果抽象一点看,这次迁移的重点不是工具升级,而是构建边界重划。

setup.py 时代很容易把一切都写成 Python 逻辑。它灵活,也正因为太灵活,项目复杂后会变成维护负担。pyproject.toml 的约束更多,但这些约束会迫使项目把元数据、构建系统、资源准备、发布流程拆开。

工程上很多改进都是这样。一开始看只是换了个配置文件,真正省下来的,是未来每一次发布、每一次下游构建、每一次问题定位的时间。

问题出现并不总是坏事。它提醒维护者:原来那个“我已经习惯了”的流程,对别人来说可能并不友好。能借这个机会把隐性规则显性化,把个人经验沉淀成标准流程,项目就会往前走一小步。[DONE]

创作声明:本内容包含AI辅助创作,观点仅供参考。