3133 字
16 分钟

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 的分工#

维度pytestmypy
检查时机运行时写代码时(静态)
验证什么行为是否正确类型是否自洽
典型 bug逻辑错误、边界遗漏返回值类型不符、None 解引用、拼写错误
运行成本需执行代码秒级扫描整库

两者互补,缺一不可。注意:加了类型 ≠ 变慢——mypy 在开发期检查,运行时该多快还多快(注解在 3.11+ 默认不保留,或用 from __future__ import annotations 全部变成字符串,零运行时开销)。

社区里 mypy 是事实标准,Pyright(微软,VS Code Pylance 底层)是另一主流选择,规则更严格、速度更快。本篇以 mypy 为主,二者配置理念相通。


二、mypy 快速上手#

2.1 安装与基础运行#

Terminal window
uv add --dev mypy
uv 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 = true
warn_redundant_casts = true
warn_no_return = true
show_error_codes = true

show_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_optionaldef 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 = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
warn_return_any = true
no_implicit_optional = true
warn_unused_ignores = true
warn_redundant_casts = true
warn_no_return = true
warn_unreachable = true
strict_equality = true

实践节奏:新项目从第一天就全开;老项目先只开 check_untyped_defs 兜底,每轮重构顺手给几个函数补标注,慢慢逼近严格。

3.3 遇到错误怎么办:局部忽略 vs 真正修复#

# ❌ 偷懒:整行忽略,掩盖真问题
result = legacy_api() # type: ignore
# ✅ 精准忽略,保留其他检查
result = legacy_api() # type: ignore[assignment]
# ✅ 更好:用 cast 显式表达「我比 mypy 更懂这里」
from typing import cast
result = 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 | None
first(("a", "b")) # 推断为 str | None

4.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"]
@overload
def parse(data: str) -> dict[str, str]: ...
@overload
def 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 社区维护:

Terminal window
uv add --dev types-requests types-redis types-PyYAML
uv run mypy src/ # 这些库现在有完整类型了

5.2 局部忽略(次选)#

cfg = weird_lib.load() # type: ignore[import-untyped]

5.3 自己写 .pyi stub(兜底)#

当某个库重要又长期无 stub,给它补一个:

# 在你的包里建 stubs/weird_lib/__init__.pyi
def 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 集成#

.pre-commit-config.yaml
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 实战#

配置就绪,一条命令打包:

Terminal window
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 本地验证产物#

发布前务必先在本地「假装安装」试一遍:

Terminal window
# 新建干净环境测试 wheel 是否真的可用
uv venv /tmp/test-env
uv pip install dist/my_package-0.1.0-py3-none-any.whl
uv run --project /tmp/test-env python -c "import my_package; print(my_package.__version__)"

8.2 检查 wheel 内容#

Terminal window
python -m zipfile -l dist/my_package-0.1.0-py3-none-any.whl
# 确认没有把 tests/、.env、密钥文件打进去

常见翻车:把 .envsecrets.json、测试数据打进 wheel。用 [tool.hatch.build.targets.wheel.force-include] 精确控制,必要时加 .gitignore 之外的构建排除规则。


九、发布到 PyPI#

9.1 注册与令牌#

  1. pypi.org 注册账号,开启 2FA(2026 年已强制)。
  2. 进入 Account Settings → API tokens → Add API token,范围选「整个账号」或指定某个项目。
  3. 记下令牌,只显示一次,形如 pypi-xxxxxxxx

9.2 先在 TestPyPI 演练(强烈建议)#

PyPI 的包名和版本号一经发布不可修改、不可删除(只能 yank)。先用沙盒镜像 TestPyPI 跑通全流程:

Terminal window
# 发布到 TestPyPI
uv publish --publish-url https://test.pypi.org/legacy/ \
--token pypi-xxxxxxxx
# 从 TestPyPI 安装验证
python -m pip install \
--index-url https://test.pypi.org/simple/ \
my-package

9.3 正式发布#

TestPyPI 验证通过,把 index 指回正式 PyPI:

Terminal window
# 环境变量方式(推荐,避免令牌出现在命令行历史)
export UV_PUBLISH_TOKEN=pypi-xxxxxxxx
uv publish # 默认发布 dist/ 下所有文件
# 或显式指定令牌
uv publish --token pypi-xxxxxxxx

uv publish 是 uv 0.6+ 原生命令,无需再装 twine。若你的环境更习惯经典工具,等价写法:

Terminal window
uv add --dev twine
uv 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 Commitsfeat: / 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 = true
.github/workflows/release.yml
name: release
on:
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-materialSphinx 自动从 docstring 生成文档站,和 PyPI 联动。
  • 安全扫描uv add --dev pip-audit(检查依赖 CVE)、bandit(检查代码安全反模式)。
  • 运行时防护:即便有类型,生产环境仍可上 pydantic 做数据边界校验。
  • 可观测性:结构化日志 + 指标,部署后才知道线上到底发生了什么。

相关文章

本文工具版本与命令以 2026 年 7 月为准。mypy、uv、python-semantic-release 迭代较快,具体 flag 与配置项请以各项目官方文档为准。

Python 项目工程化实战(三):mypy 类型检查 + uv build 打包发布 PyPI 全流程 | Python 进阶
https://971918.xyz/posts/python-guide/python-project-engineering-03/
作者
九所长
发布于
2026-07-23
许可协议
CC BY-NC-SA 4.0