Python 工程化实践
写脚本和写生产 Python 是两回事。本文讲清 Python 工程化的关键:依赖管理(uv/poetry/pip-tools)、类型标注与 mypy、项目结构、测试(pytest)、打包发布、以及从"能跑的脚本"到"可维护的工程项目"的转变。
本文要点
- 主题聚焦:AI、开源
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
Python 工程化实践:从"能跑的脚本"到"可维护的工程"
Python 是一门让你快速把想法变成代码的语言。但"能跑起来"和"能在生产环境长期维护下去"之间,隔着一整套工程化体系。很多团队踩坑,不是因为 Python 不行,而是把脚本思维原封不动地搬到了工程项目里——没有依赖锁、没有类型检查、没有测试、目录随性、发布靠 scp。
这篇文章把 Python 工程化的关键节点讲清楚:依赖管理、类型标注、项目结构、测试、打包发布、代码质量。每一项都是"你早晚会补上"的功课,晚补不如早补。
一、脚本思维 vs 工程思维
先说清楚边界。一个一次性的数据清洗脚本,200 行,跑完就扔,工程化是过度设计。但只要你面临下面任何一种情况,就该上工程化:
- 代码会被多人协作修改;
- 代码会运行超过 3 个月,需求还会变;
- 代码要部署到测试、预发、生产多个环境;
- 代码会被其他服务或包 import。
脚本思维关注"今天能跑通",工程思维关注"半年后还能跑通、新人接手能继续跑、依赖升级不会炸"。两者的核心差别在四个维度:结构化、类型约束、测试覆盖、依赖锁定。后文每一节都是在补这四个维度中的一个。
二、依赖管理:别再 pip install 到全局
requirements.txt 是最原始的方案,但它有两个致命问题:不锁传递依赖、不隔离环境。你的 requirements.txt 里写 fastapi,三个月后新人装上的是最新版,接口已经不兼容。
依赖管理的演进大致是这样的:
requirements.txt(原始):只锁定你直接声明的包,传递依赖靠pip freeze > requirements.txt一次性快照。能用,但不可维护。pip-tools(锁版本):requirements.in写直接依赖,pip-compile生成完全锁定的requirements.txt,pip-sync让本地环境和锁文件一致。轻量、不侵入,适合不想换工具链的老项目。poetry/uv(现代):统一的pyproject.toml+ 锁文件 + 虚拟环境管理,一站式。
uv 是目前的首选推荐。 它用 Rust 写的,安装和解析依赖比 poetry 快一到两个数量级,集成了 Python 版本管理、虚拟环境、包安装、发布于一身。一个命令搞定:
uv init my-project
uv add fastapi pydantic
uv add --dev pytest mypy ruff
uv lock
uv sync
生成的 uv.lock 提交到仓库,任何人 uv sync 就能得到和你完全一致的环境,包括 Python 版本。这是工程化的底线:环境可复现。
如果团队还在用 poetry,也没必要强行迁移,poetry 的 poetry.lock 同样能保证可复现性。但坚决不要再用全局 pip install 加系统 Python 那一套了——那是脚本时代的产物。
三、类型标注 + mypy/pyright:大型项目的必选项
Python 是动态类型语言,这是它的灵活来源,也是大型项目的崩溃来源。函数签名没有类型,重构时你根本不知道一个字典里有哪些 key,IDE 跳转跳不准,改一行炸十处。
类型标注(typing 模块、PEP 484)就是把静态类型检查引入 Python。基本语法:
from typing import Optional
def get_user(user_id: int) -> Optional[User]:
if user_id <= 0:
return None
return User(id=user_id)
渐进式引入是关键。 不要指望一次性把老项目全部标上类型。正确的姿势是:
- 新代码必须带类型标注(用 PR review 卡);
- 公共 API、核心数据模型先标;
- 用 mypy 的
--strict模式逐步开,先--disallow-untyped-defs,再--disallow-incomplete-defs,最后整体 strict。
工具选择上,pyright(含 Pylance)更适合编辑器内实时反馈,速度快、错误信息友好;mypy 更适合 CI 卡点,生态成熟、配置灵活。两者可以并用:本地 pyright 实时,CI mypy 严格。配置写在 pyproject.toml:
[tool.mypy]
python_version = "3.12"
strict = true
warn_unused_ignores = true
ignore_missing_imports = true
类型标注不是装饰,它是一种"可执行文档"——类型系统会帮你校验这文档和代码是否一致。
四、项目结构:src 布局 + pyproject.toml
新手 Python 项目的典型结构是这样的:
my_project/
├── main.py
├── utils.py
├── config.py
└── data.db
这种"扁平目录"在 100 行以内还能凑合,一旦超过几百行就开始打架。生产项目应该用 src layout:
my-project/
├── pyproject.toml
├── README.md
├── src/
│ └── my_project/
│ ├── __init__.py
│ ├── api/
│ │ ├── __init__.py
│ │ └── routes.py
│ ├── models/
│ │ └── user.py
│ ├── services/
│ │ └── auth.py
│ └── config.py
├── tests/
│ ├── conftest.py
│ ├── test_api.py
│ └── test_services.py
└── .github/workflows/ci.yml
src layout 的好处是:强制你以"安装后的包"的方式 import,而不是直接从当前目录 import。这能暴露很多隐式依赖问题,也避免测试时误用了源码目录而非安装版本。
所有项目元数据集中到 pyproject.toml(PEP 517/518/621),不要再散落在 setup.py、setup.cfg、requirements.txt、MANIFEST.in 一堆文件里。一个 pyproject.toml 解决所有问题:
[project]
name = "my-project"
version = "0.1.0"
description = "一个示例项目"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.110",
"pydantic>=2.0",
]
[project.optional-dependencies]
dev = ["pytest", "mypy", "ruff", "pytest-cov"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--strict-markers --cov=my_project"
五、测试:pytest 是事实标准
测试这件事,Python 生态早就有了共识:pytest。比 unittest 简洁十倍,断言就是 assert,不用记一堆 self.assertEqual。
def test_create_user():
user = create_user(name="alice")
assert user.name == "alice"
assert user.id > 0
三个工程化要点:
第一,用 fixture 管理测试依赖。 数据库连接、测试客户端、假数据,全用 conftest.py 里的 fixture 提供,作用域明确(scope="session" / "function"),可复用、可组合:
import pytest
@pytest.fixture
def db_session():
session = SessionLocal()
yield session
session.rollback()
session.close()
def test_create_user(db_session):
user = create_user(db_session, name="alice")
assert user.id is not None
第二,覆盖率要看,但别迷信。 CI 里跑 pytest --cov,核心模块覆盖率目标 80% 以上是合理的,但别为了刷分而写无意义的测试。覆盖率只能告诉你"哪些代码没跑过",不能告诉你"跑过的代码对不对"。
第三,mock 要克制。 能用真实对象就用真实对象(比如用 SQLite 内存数据库测 ORM,而不是 mock 一堆 Session 方法)。过度 mock 会让测试和实现强耦合,重构一次测试全红。
六、打包与发布:pyproject.toml + build
如果项目要被其他项目 import,或者要发到 PyPI / 内部源,就要打包。现代流程:
uv build # 生成 dist/ 下的 wheel 和 sdist
uv publish # 发布到 PyPI(或配置内部源)
底层工具是 python -m build 生成产物,twine 上传。pyproject.toml 里 [build-system] 声明构建后端(hatchling、setuptools、flit 等),构建过程完全标准化。
发到内部源(比如公司私有 PyPI)只需要配置 ~/.pypirc 或在 CI 里设 UV_PUBLISH_URL / TWINE_REPOSITORY_URL 环境变量。版本号建议遵循 SemVer,自动化可以用 uv version 或 bump-my-version。
一个常被忽视的细节:发布前要确保锁文件和 pyproject.toml 一致,且 pyproject.toml 里的 dependencies 用的是宽松版本约束(比如 >=0.110 而不是 ==0.110.2),否则用户安装时会被你的锁死。
七、代码质量:ruff + pre-commit
ruff 是这一波 Python 工具链最大的进步。 一个 Rust 写的工具,同时替代 flake8(lint)、black(format)、isort(import 排序)、pyupgrade、isort、pydocstyle 等十几个工具,速度快到几乎无感。配置全在 pyproject.toml:
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM", "RET"]
ignore = ["E501"]
[tool.ruff.format]
quote-style = "double"
ruff check . # lint
ruff check --fix . # 自动修复
ruff format . # 格式化
pre-commit hook 把这些检查卡在 commit 之前,避免低级问题进入仓库:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.0
hooks:
- id: mypy
pre-commit install # 装到 git hook
CI 里再跑一遍完整的 ruff check + mypy + pytest,保证本地 hook 被绕过时还有一道防线。
小结
把上面七节压成可落地的清单:
- 依赖:换 uv(或 poetry),
pyproject.toml+ 锁文件,环境可复现;坚决告别全局pip install。 - 类型:新代码强制类型标注,mypy/pyright 渐进式开 strict;公共 API 先行。
- 结构:src layout + 集中式
pyproject.toml,元数据不要再散落。 - 测试:pytest + fixture + 覆盖率,mock 克制,核心模块卡 80%。
- 质量:ruff 一把梭(pre-commit hook + CI 双重卡点)。
工程化不是炫技,是给未来的自己和团队买保险。能跑的代码只能证明今天对,可维护的代码才能让明天继续对。 从脚本到工程的距离,就是把"约定"变成"工具强制"的距离——每一次 ruff check 通过、每一次 mypy 静默、每一次 uv sync 还原出干净环境,都是在把项目的寿命往更远处推。
Related