Python 项目工程化实战(三):mypy 类型检查 + uv build 打包发布 PyPI 全流程 | Python 进阶
前两篇我们搭好了项目结构 + 工具链(第一篇)和测试体系 + CI 门槛(第二篇)。但还差两件事没解决:
- 类型安全:Python 运行时不做类型检查,一个
None解引用、一个返回值类型写错,要等到线上才炸。 - 交付闭环:代码写得再漂亮,最终要能变成别人
pip install就能用的包,并自动发布上线。
本篇(终篇)就把这条链路补全:
写代码 → mypy 类型守护 → pytest 测试 → uv build 打包 → uv publish 上线一、为什么需要静态类型检查?
Python 在 3.5 引入类型注解(PEP 484),但注解只是提示,运行时完全不检查:
def add(a: int, b: int) -> int: return a + b
add("hello", "world") # ✅ 运行时照样能跑,返回 "helloworld"add(1, None) # ✅ 运行时才抛 TypeError类型注解单独存在时,价值只有「给 IDE 和阅读者看」。要让它真正挡住 bug,需要一个静态类型检查器(type checker)——在不运行代码的前提下扫描全部标注,在开发期就报错。
# 用 mypy 检查上面这段# mypy 会报:# error: Argument 1 to "add" has incompatible type "str"; expected "int"# error: Argument 2 to "add" has incompatible type "None"; expected "int"mypy 与 pytest 的分工
| 维度 | pytest | mypy |
|---|---|---|
| 检查时机 | 运行时 | 写代码时(静态) |
| 验证什么 | 行为是否正确 | 类型是否自洽 |
| 典型 bug | 逻辑错误、边界遗漏 | 返回值类型不符、None 解引用、拼写错误 |
| 运行成本 | 需执行代码 | 秒级扫描整库 |
两者互补,缺一不可。注意:加了类型 ≠ 变慢——mypy 在开发期检查,运行时该多快还多快(注解在 3.11+ 默认不保留,或用 from __future__ import annotations 全部变成字符串,零运行时开销)。
社区里 mypy 是事实标准,Pyright(微软,VS Code Pylance 底层)是另一主流选择,规则更严格、速度更快。本篇以 mypy 为主,二者配置理念相通。
二、mypy 快速上手
2.1 安装与基础运行
uv add --dev mypyuv run mypy src/your_package# Success: no issues found in 23 source files如果项目里已有类型注解,第一次跑大概率会冒出一堆错误——这是好事,说明它在干活。
2.2 接入 pyproject.toml
和 Ruff 一样,把配置写进 pyproject.toml 的 [tool.mypy],不再单独维护 mypy.ini:
[tool.mypy]python_version = "3.12"files = ["src", "tests"]warn_unused_ignores = truewarn_redundant_casts = truewarn_no_return = trueshow_error_codes = trueshow_error_codes = true 让每条错误带代码(如 [arg-type]),方便你精准写 # type: ignore[arg-type]。
三、渐进式 → 严格模式
新手最容易犯的错:一上来开 --strict,被几百条报错劝退,从此再也不跑 mypy。正确做法是渐进式开启。
3.1 核心检查项(建议逐个打开)
| 配置项 | 作用 | 强烈建议 |
|---|---|---|
disallow_untyped_defs | 禁止没有类型标注的函数 | ✅ |
disallow_incomplete_defs | 禁止部分参数缺标注 | ✅ |
check_untyped_defs | 即便没标注也尽力检查函数体 | ✅ |
disallow_untyped_decorators | 装饰器必须带类型 | ✅ |
warn_return_any | 返回 Any 时告警 | ✅ |
no_implicit_optional | def f(x=None) 必须写成 x: int | None | ✅ |
warn_unreachable | 检测不可达代码 | ✅ |
strict_equality | 类型不可能相等时告警(如 int == str) | ✅ |
3.2 用严格模式组合拳
mypy 提供 --strict 一次性打开约 20 个子开关。推荐不要直接用 —strict,而是显式列出你接受的项,团队可维护、可解释:
[tool.mypy]python_version = "3.12"files = ["src", "tests"]show_error_codes = true
# —— 逐步开启的严格子项 ——disallow_untyped_defs = truedisallow_incomplete_defs = truecheck_untyped_defs = truedisallow_untyped_decorators = truewarn_return_any = trueno_implicit_optional = truewarn_unused_ignores = truewarn_redundant_casts = truewarn_no_return = truewarn_unreachable = truestrict_equality = true实践节奏:新项目从第一天就全开;老项目先只开
check_untyped_defs兜底,每轮重构顺手给几个函数补标注,慢慢逼近严格。
3.3 遇到错误怎么办:局部忽略 vs 真正修复
# ❌ 偷懒:整行忽略,掩盖真问题result = legacy_api() # type: ignore
# ✅ 精准忽略,保留其他检查result = legacy_api() # type: ignore[assignment]
# ✅ 更好:用 cast 显式表达「我比 mypy 更懂这里」from typing import castresult = cast(Order, legacy_api())原则:能用类型表达就别 ignore,# type: ignore 是最后手段,且务必带 error code。
四、类型进阶实战
光会标 int / str 不够,工程里满是高阶类型。
4.1 泛型与 TypeVar
from typing import TypeVar, Sequence
T = TypeVar("T")
def first(items: Sequence[T]) -> T | None: return items[0] if items else None
first([1, 2, 3]) # 推断为 int | Nonefirst(("a", "b")) # 推断为 str | None4.2 可调用对象 Callable
from collections.abc import Callable
def retry(times: int, fn: Callable[[int], str]) -> str: for i in range(times): result = fn(i) if result: return result return ""4.3 Protocol(结构化子类型,鸭子类型的正解)
from typing import Protocol
class Renderable(Protocol): def render(self) -> str: ...
def draw(obj: Renderable) -> None: print(obj.render())
class Circle: def render(self) -> str: # 不需要显式继承,只要有 render 就满足 return "○"
draw(Circle()) # ✅ mypy 通过4.4 Literal 与 overload
from typing import Literal, overload
Mode = Literal["fast", "slow"]
@overloaddef parse(data: str) -> dict[str, str]: ...@overloaddef parse(data: bytes) -> dict[str, int]: ...
def parse(data: str | bytes) -> dict[str, str] | dict[str, int]: # 实现体 ...overload 让「不同类型入参返回不同类型」在调用侧也能被精确检查。
4.5 reveal_type 调试类型
x = [1, "a", 3.0]reveal_type(x) # mypy 输出:Revealed type is "list[int | str | float]"遇到推断不对,用 reveal_type 打印 mypy 看到的真实类型,比猜高效十倍。
五、第三方库没类型怎么办?
mypy 对「找不到类型的调用」默认返回 Any——能通过,但那一块成了类型盲区。三种处理方式,按推荐顺序:
5.1 安装官方 stub 包(首选)
命名惯例 types-<库名>,由 typeshed 社区维护:
uv add --dev types-requests types-redis types-PyYAMLuv run mypy src/ # 这些库现在有完整类型了5.2 局部忽略(次选)
cfg = weird_lib.load() # type: ignore[import-untyped]5.3 自己写 .pyi stub(兜底)
当某个库重要又长期无 stub,给它补一个:
# 在你的包里建 stubs/weird_lib/__init__.pyidef load(path: str) -> dict[str, str]: ...def save(data: dict[str, str], path: str) -> None: ...# pyproject.toml 告诉 mypy 去哪找 stub[tool.mypy]mypy_path = "stubs"写了有价值的 stub,记得反哺社区(提 PR 到 typeshed 对应的 types-* 仓库),这是 Python 类型生态能越来越好的原因。
六、把 mypy 接进质量门禁
类型检查必须和测试一样,进 pre-commit 和 CI,否则迟早被跳过。
6.1 pre-commit 集成
repos: - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.13.0 hooks: - id: mypy additional_dependencies: [types-requests, types-redis] args: [--config-file=pyproject.toml]6.2 CI 质量门禁(接在 pytest 之后)
# .github/workflows/ci.yml(节选) type-check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 - run: uv sync --all-extras - name: mypy run: uv run mypy src tests - name: pytest run: uv run pytest --cov --cov-fail-under=80现在每次 PR:类型不过 → 门禁红;覆盖率不够 → 门禁红。质量在合并前就被卡住。
七、打包:让代码变成可安装的包
类型守护好了,下一步是把代码打成别人能 pip install 的包。现代打包只靠 pyproject.toml 的 [project] 表(PEP 621),不再需要 setup.py。
7.1 最小可用 [project] 配置
[project]name = "my-package"version = "0.1.0"description = "一个示例 Python 库"readme = "README.md"requires-python = ">=3.10"license = { text = "MIT" }authors = [{ name = "九所长", email = "hi@971918.xyz" }]keywords = ["example", "demo"]dependencies = [ "httpx>=0.27", "pydantic>=2.0",]
[project.urls]Homepage = "https://github.com/yourname/my-package"Documentation = "https://my-package.readthedocs.io"
# 可选:命令行入口(安装后可直接在终端运行 my-cli)[project.scripts]my-cli = "my_package.cli:main"7.2 指定构建后端
构建后端决定「怎么把源码变成 wheel」。2026 年推荐 hatchling(uv 默认)或 setuptools:
[build-system]requires = ["hatchling"]build-backend = "hatchling.build"7.3 包发现(src layout 必须配)
用 src layout 时(强烈推荐,避免「测试时导入的是本地目录而非已安装包」的坑),要明确告诉后端包在哪:
[tool.hatch.build.targets.wheel]packages = ["src/my_package"]完整目录结构回顾:
my-package/├── pyproject.toml # 唯一配置 + 打包元数据├── README.md├── LICENSE├── src/│ └── my_package/│ ├── __init__.py│ └── core.py├── tests/│ └── test_core.py└── uv.lock八、uv build 实战
配置就绪,一条命令打包:
uv build# Building source distribution...# Building wheel...# 产物落在 dist/ls dist/# my_package-0.1.0.tar.gz (sdist 源码分发)# my_package-0.1.0-py3-none-any.whl (wheel 二进制分发)uv build 的优势:
- 隔离构建环境——不会往你当前虚拟环境塞一堆 build 依赖
- 复用缓存——uv 已经解析过的依赖直接复用,不必重装
- 速度快——Rust 实现 + 并行,比
python -m build快一个量级
8.1 本地验证产物
发布前务必先在本地「假装安装」试一遍:
# 新建干净环境测试 wheel 是否真的可用uv venv /tmp/test-envuv pip install dist/my_package-0.1.0-py3-none-any.whluv run --project /tmp/test-env python -c "import my_package; print(my_package.__version__)"8.2 检查 wheel 内容
python -m zipfile -l dist/my_package-0.1.0-py3-none-any.whl# 确认没有把 tests/、.env、密钥文件打进去常见翻车:把
.env、secrets.json、测试数据打进 wheel。用[tool.hatch.build.targets.wheel.force-include]精确控制,必要时加.gitignore之外的构建排除规则。
九、发布到 PyPI
9.1 注册与令牌
- 在 pypi.org 注册账号,开启 2FA(2026 年已强制)。
- 进入 Account Settings → API tokens → Add API token,范围选「整个账号」或指定某个项目。
- 记下令牌,只显示一次,形如
pypi-xxxxxxxx。
9.2 先在 TestPyPI 演练(强烈建议)
PyPI 的包名和版本号一经发布不可修改、不可删除(只能 yank)。先用沙盒镜像 TestPyPI 跑通全流程:
# 发布到 TestPyPIuv publish --publish-url https://test.pypi.org/legacy/ \ --token pypi-xxxxxxxx
# 从 TestPyPI 安装验证python -m pip install \ --index-url https://test.pypi.org/simple/ \ my-package9.3 正式发布
TestPyPI 验证通过,把 index 指回正式 PyPI:
# 环境变量方式(推荐,避免令牌出现在命令行历史)export UV_PUBLISH_TOKEN=pypi-xxxxxxxxuv publish # 默认发布 dist/ 下所有文件
# 或显式指定令牌uv publish --token pypi-xxxxxxxxuv publish 是 uv 0.6+ 原生命令,无需再装 twine。若你的环境更习惯经典工具,等价写法:
uv add --dev twineuv run twine upload dist/*9.4 版本号规范(SemVer)
主版本.次版本.修订号 如 1.4.2└─ 不兼容的 API 变更(破坏性) └─ 向下兼容的功能新增 └─ 向下兼容的 bug 修复预发布用后缀:1.0.0a1(alpha)、1.0.0rc1(release candidate)。PyPI 会按 PEP 440 正确排序。
十、自动化版本与发布
每次手动 bump 版本号 + 打 tag + 发布,容易漏也容易错。用 python-semantic-release(原名 semantic-release)让 CI 全自动:
10.1 工作原理
基于 Conventional Commits(feat: / fix: / BREAKING CHANGE:)自动判断版本号,生成 changelog、打 git tag、构建并发布:
| Commit 前缀 | 版本变化 |
|---|---|
fix: | 修订号 +1(1.0.0 → 1.0.1) |
feat: | 次版本 +1(1.0.0 → 1.1.0) |
BREAKING CHANGE: | 主版本 +1(1.0.0 → 2.0.0) |
10.2 配置
[tool.semantic_release]version_variables = ["src/my_package/__init__.py:__version__"]version_toml = "pyproject.toml:project.version"build_command = "uv build"dist_glob = "dist/*"branch = "main"upload_to_pypi = truename: releaseon: push: branches: [main]jobs: release: runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/checkout@v4 with: { fetch-depth: 0 } - uses: astral-sh/setup-uv@v5 - name: Python Semantic Release uses: python-semantic-release/python-semantic-release@v9 with: github_token: ${{ secrets.GITHUB_TOKEN }} env: UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}之后你只需要:git commit -m "feat: 支持批量导出" → push 到 main → CI 自动 bump 版本、打 tag、构建、发布到 PyPI、更新 changelog。人类只写代码,机器管发布。
十一、工程化系列完整小结
| 环节 | 工具 | 本系列位置 |
|---|---|---|
| 项目结构 / 依赖 / 格式化 | pyproject.toml + uv + Ruff | 第一篇 |
| 测试体系 / 覆盖率 / CI 门槛 | pytest + pytest-cov + CI | 第二篇 |
| 类型检查 / 打包 / 发布 | mypy + uv build + PyPI | 本篇(第三篇,终篇) |
三篇走完,一个 2026 年现代 Python 库已经拥有完整工程底座:
统一配置 pyproject.toml ├─ 极速依赖管理 uv + uv.lock ├─ 自动格式化 + lint Ruff ├─ 静态类型守护 mypy(strict 渐进) ├─ 测试 + 覆盖率门槛 pytest + pytest-cov ├─ 质量门禁 pre-commit + CI ├─ 打包 uv build(wheel + sdist) └─ 自动化发布 PyPI(python-semantic-release)这套组合不是「银弹」——小脚本、一次性分析脚本没必要上全套。但对要被别人依赖、要长期维护、要多人协作的库和项目,它是目前社区公认的最低成本高质量方案。
十二、工程化之外:下一步还能做什么?
三篇工程化实战到这就收尾了。如果你还想把项目打磨到「工业级」,可以继续探索:
- 文档:用
mkdocs-material或Sphinx自动从 docstring 生成文档站,和 PyPI 联动。 - 安全扫描:
uv add --dev pip-audit(检查依赖 CVE)、bandit(检查代码安全反模式)。 - 运行时防护:即便有类型,生产环境仍可上
pydantic做数据边界校验。 - 可观测性:结构化日志 + 指标,部署后才知道线上到底发生了什么。
相关文章:
- Python 项目工程化实战(一):pyproject.toml + uv + Ruff 现代工具链
- Python 项目工程化实战(二):pytest 测试体系建设从 fixtures 到 CI 门槛
- Python 类型注解完全指南:从基础标注到 Pydantic 数据验证
- FastAPI 完全指南:从零构建高性能异步 API 到 Docker 生产部署
本文工具版本与命令以 2026 年 7 月为准。mypy、uv、python-semantic-release 迭代较快,具体 flag 与配置项请以各项目官方文档为准。