从写脚本到做工程:Python 项目工程化五件套一次讲透

目录
2.2 传统方案链路:venv + pip + pip-tools
前言
很多人 Python 写得挺溜,可一旦要把脚本做成"项目",问题就来了:
- 同一台电脑,A 项目要
requests==2.28,B 项目要requests==2.31,装谁谁打架; - 拉下同事的代码一跑,
ImportError、ModuleNotFoundError满屏飞; - 自己的项目里几十个
.py全堆在根目录,文件名起得像utils_v2_final.py; - 依赖列表昨天还能跑,今天
pip install一下就崩——因为没人锁过版本。
这些坑其实都不是"语法不行",而是没系统接触过工程化。Python 这门语言入门简单,但要把一个项目做"规范、可维护、能协作",得补上一组工程化基本功。这篇文章就把其中最核心的五件套一次讲透。
它们不是并列关系,而是一条递进主线:
版本定地基 → 环境做隔离 → 依赖锁版本 → 配置收一处 → 结构定规矩
一、Python 版本选择与版本兼容
1.1 为什么不能"随便用系统自带的 Python"
很多人初学时直接用 macOS / Linux 系统自带的那个 python3,觉得省事。但系统 Python 是给操作系统自己用的,它的版本由发行版决定,你动它(升级、装全局包)可能影响系统工具;而且系统自带版本往往偏旧,新语法、新标准库用不了。
正确的姿势是:自己装一份独立管理的 Python(用 uv、pyenv、官方安装包等),项目爱用哪个版本就用哪个,和系统 Python 彻底解耦。
1.2 2026 年版本怎么选
讲版本离不开一个词:EOL(End of Life,生命周期终点)。Python 每个版本官方会给大约 5 年支持期,过期后不再有安全补丁。选版本第一原则就是——别选已经 EOL 或快 EOL 的版本。
截至 2026 年,主流版本的现状如下:
|
Python 版本 |
发布年份 |
EOL 时间 |
定位(2026 年视角) |
关键新特性 |
|
3.9 |
2020 |
2025-10(已 EOL) |
不再推荐 |
字典合并运算符 |
|
3.10 |
2021 |
2026-10(临近 EOL) |
仅在维护老项目时碰 |
结构化模式匹配 |
|
3.11 |
2022 |
2027-10 |
兼容性下限常用值 |
解释器提速 10–60% |
|
3.12 |
2023 |
2028-10 |
求稳首选 |
更好的错误提示、f-string 语法放宽 |
|
3.13 |
2024 |
2029-10 |
生产新项目首选 |
实验性 free-threading、改进 REPL |
|
3.14 |
2025 |
2030-10 |
较新,可尝鲜 |
模板字符串、尾调用解释器 |
一句话建议:新项目主选 3.13;团队求稳、依赖还没完全适配 3.13 的,选 3.12。requires-python 下限设到 3.11 或 3.10 是性价比比较高的兼容区间。
生活化类比:选 Python 版本就像给房子打地基。地基版本定了,上面盖的楼(你的代码、依赖库)才稳。而老房子(老旧第三方库)能不能撑住新地基,得看"兼容性"——这就是下一节要解决的。
1.3 版本兼容三件套
工程项目要同时回答两个问题:这个项目至少需要哪个版本的 Python? 以及 运行时如何根据版本做差异化处理? 三件套分别解决:
# ① 在 pyproject.toml 里锁下限 —— 声明式,安装阶段就拦截不兼容版本
# [project]
# requires-python = ">=3.11"
# ② 运行时检查版本 —— 程序内根据版本走不同逻辑
import sys
if sys.version_info < (3, 12):
# 3.12 之前的写法
from typing_extensions import TypedDict
else:
# 3.12+ 已内置
from typing import TypedDict
# ③ 用 __future__ 提前"借用"新版本的语义(如注解延迟求值)
from __future__ import annotations
三者各司其职:requires-python 是闸门(早期拦截),sys.version_info 是开关(运行时分流),__future__ 是预尝鲜(让旧版本用上新语义)。工程项目里,第一个最常用、最重要。
这一章是 P1,"熟悉"即可,不用死记。重点是把"不能用系统 Python、要选仍在支持的版本、用 requires-python 锁下限"这三件事记牢,剩下都是查文档的事。
二、uv、虚拟环境与依赖管理
这是全文最重的一章。Python 工程化的几乎所有早期痛苦,都源于环境与依赖没管好。
2.1 依赖地狱是怎么来的
设想这个场景:你打开终端,敲下
pip install requests==2.28
这条命令默认会把 requests 装到系统全局的 Python 里。下次你做 B 项目,又敲:
pip install requests==2.31
系统里的 requests 就被升级了,A 项目再一跑,行为变了,甚至直接报错——因为它依赖的是旧版本的某个 API。项目越多、依赖越乱,最后谁也不知道某个库到底装的是什么版本。这就是俗称的依赖地狱(Dependency Hell)。
破局之道只有一个:给每个项目一个独立的 Python 环境,互不串味。
生活化类比:虚拟环境就像给每个项目分一间独立工作间。木工间放木工工具、电工间放电工工具,互不串味。你在 A 项目工作间装 requests==2.28,B 项目工作间装 requests==2.31,两不相干。
2.2 传统方案链路:venv + pip + pip-tools
长久以来,标准的做法是这样的三件套:
# ① 创建虚拟环境(在项目目录下生成一个 .venv 文件夹)
python -m venv .venv
# ② 激活环境(Windows / 类 Unix 命令不同)
# Windows:
.venv\Scripts\activate
# macOS / Linux:
source .venv/bin/activate
# ③ 在激活的环境里装包
pip install requests pandas
# ④ 导出依赖(生成 requirements.txt)
pip freeze > requirements.txt
# ⑤ 别人拉到项目后,复现你的环境
pip install -r requirements.txt
这套流程能跑通,但有几个固有缺陷,越往大项目走越痛:
|
缺陷 |
说明 |
后果 |
|
|
只记录"装了什么版本",不记录 hash |
别人安装时可能拿到被篡改/不同的包 |
|
不区分直接依赖与间接依赖 |
|
升级困难,分不清哪些是你要的、哪些是连带装的 |
|
速度慢 |
pip 是纯 Python 实现,串行下载 |
大项目装一次几分钟,调试体验差 |
|
工具割裂 |
venv、pip、pip-tools、virtualenv 各管一摊 |
心智负担重,新手容易搞混 |
为了缓解第 1、2 个问题,社区搞出了 pip-tools(pip-compile + pip-sync),能生成带 hash 的 .lock 风格文件。但工具链依然割裂、速度依然慢。直到 uv 出现,局面才被彻底改写。
2.3 现代方案:uv 一把梭
uv 是 Astral 公司(也就是做 Ruff 那家)用 Rust 重写的 Python 包管理器。它一出场就把 venv + pip + pip-tools(甚至还能替代 pipx、pyenv 的一部分职责)整合到了一个命令里。
它的"快"不是吹的:底层 Rust 实现、全局缓存(同一个包只下一次,不同项目复用)、并行下载。实测大项目装依赖,从 pip 的两三分钟降到 uv 的几秒。
更重要的是,uv 把虚拟环境和依赖锁文件都内化了,你不用再手动 activate、不用纠结要不要提交 requirements.txt。核心就一个命令:uv sync。
# ① 新建项目(自动建好 pyproject.toml + .venv + 示例结构)
uv init my_project
cd my_project
# ② 加依赖(自动解析、自动锁定、自动写入 .venv)
uv add requests pandas
# ③ 加开发依赖(不进生产)
uv add --dev pytest ruff
# ④ 同步环境(别人拉到项目后只需这一条命令)
uv sync
它的工作流是这样的:
pyproject.toml里只写你直接要的依赖(requests、pandas),不写版本号或只写宽松约束。uv.lock是 uv 自动生成的锁文件,精确记录每个包(包括间接依赖)的版本和 hash,应当提交到 Git。.venv/是实际环境,不提交(加进.gitignore)。- 任何人拿到代码,敲
uv sync,uv 会读uv.lock,精确复现你当时的环境——hash 都对得上。
这一套解决了第 2.2 节列的全部缺陷:锁文件带 hash、自动区分直接/间接依赖、快、工具统一。
下面这张对照表建议收藏,迁移时照着查:
|
你想做的事 |
传统方案 |
uv 方案 |
|
创建虚拟环境 |
|
|
|
激活环境 |
|
不用手动激活,uv 命令自动用项目 |
|
装一个包 |
|
|
|
装开发依赖 |
|
|
|
锁定依赖 |
|
自动生成 |
|
精确复现环境 |
|
|
|
升级某个包 |
|
|
|
全局跑工具 |
|
|
|
指定 Python 版本 |
|
|
2.4 一次 uv sync 背后发生了什么
理解 uv 的流程,对你排查"为什么 sync 后还是报错"很有帮助。下面这张流程图把 uv sync 拆开了:
看懂这个流程,几个常见疑问就豁然开朗:
- "我改了
pyproject.toml,为什么环境没变?" —— 因为还没uv sync,uv 不会主动改.venv。 - "lock 文件该不该提交?" —— 必须提交,它是团队复现环境的唯一依据。
- "为什么 sync 比第一次快那么多?" —— 全局缓存命中,包不用重下。
2.5 常见踩坑
- 忘了在项目根目录操作:uv 靠
pyproject.toml识别项目,cd 错目录就找不到。 - 手动
pip install到全局:习惯没改过来,绕过了 uv,依赖又乱套。养成"装包只走uv add"的习惯。 .venv误提交:它不该进 Git,uv init默认会生成.gitignore帮你挡掉,但自己要确认。uv.lock没提交:团队协作的致命错误,导致同事环境各装各的。
这章是全文 P0 中的 P0。建议你立刻新建一个空目录跑一遍 uv init → uv add → uv sync,比读十遍文档都有用。
三、pyproject.toml
上一章你已经反复看到 pyproject.toml 这个文件了,它就是 Python 现代工程的配置中枢。这一章专门讲它。
3.1 它解决了什么:碎片化配置的终结
在 pyproject.toml 之前,一个 Python 项目的配置散落在五六个文件里:
|
旧文件 |
管什么 |
痛点 |
|
|
打包、元数据、依赖 |
可执行代码,易出错、难审计 |
|
|
运行依赖 |
不锁间接依赖、无 hash |
|
|
开发依赖 |
又一个文件要维护 |
|
|
静态元数据、工具配置 |
和 setup.py 职责重叠 |
|
|
代码检查配置 |
每个工具一个文件 |
|
|
打包额外文件 |
容易漏 |
PEP 518 和 PEP 621 出来后,社区达成共识:所有配置收进一个 pyproject.toml。从此项目根目录清爽多了。
生活化类比:pyproject.toml 就像项目的"身份证 + 说明书"。别人(或机器:构建工具、IDE、CI)拿到它,一眼就知道:这项目叫什么、谁做的、依赖什么、怎么构建、用什么工具检查。
3.2 三大核心区块
一个完整的 pyproject.toml 通常分三块,理解这三块就够了:
# ① [project] —— 项目元数据 + 依赖(PEP 621 定义)
[project]
name = "my_project"
version = "0.1.0"
description = "一个示例项目"
requires-python = ">=3.11"
dependencies = [
"requests>=2.31",
"pandas>=2.0",
]
# ② [build-system] —— 怎么打包这个项目(PEP 518 定义)
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
# ③ [tool.*] —— 各种工具的配置(ruff、pytest、uv 等都收这儿)
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.uv]
dev-dependencies = ["pytest", "ruff"]
三块职责清晰:
[project]:是什么(名字、版本、依赖)。这是最核心的一块,几乎所有工具都会读它。[build-system]:怎么打包。指定构建后端(hatchling、setuptools、flit 等)。如果你只是写个不可分发的脚本应用,可以加[tool.uv] package = false跳过打包。[tool.*]:怎么用工具。每个工具一个子表,集中管理。
3.3 从最小可用到生产可用
不要被"完整配置"吓到,实际开发是渐进的。下面看一个最小可用版本:
# 最小版本:能跑就行
[project]
name = "my_project"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["requests"]
随着项目长大,逐步丰富成生产可用版:
[project]
name = "my_project"
version = "0.1.0"
description = "一个数据采集与处理的服务"
readme = "README.md"
requires-python = ">=3.11"
authors = [{ name = "your_name", email = "you@example.com" }]
license = { text = "MIT" }
keywords = ["data", "crawler"]
# 运行依赖:宽松约束,由 uv.lock 锁死精确版本
dependencies = [
"requests>=2.31",
"pandas>=2.0,<3.0",
"pydantic>=2",
]
# 可选依赖组:装的时候按需选
# uv pip install -e ".[dev]" 或 uv sync --extra dev
[project.optional-dependencies]
dev = ["pytest>=8", "ruff>=0.5"]
docs = ["mkdocs", "mkdocs-material"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.uv]
dev-dependencies = ["pytest>=8", "ruff>=0.5"]
注意 dependencies 和 [tool.uv].dev-dependencies 的区别:前者是生产也要装的运行依赖,后者是只在开发时装的工具链(pytest、ruff 之类,不该进生产镜像)。[project.optional-dependencies] 则是给"按需功能"分组用的。
3.4 高频字段速查表
|
字段 / 区块 |
作用 |
示例 |
|
|
包名(必填,发布到 PyPI 的名字) |
|
|
|
版本号(必填) |
|
|
|
支持的 Python 版本下限 |
|
|
|
运行依赖列表 |
|
|
|
可选依赖组(功能/分组) |
|
|
|
开发依赖(不进生产) |
|
|
|
构建后端 |
|
|
|
Ruff 代码检查配置 |
|
|
|
pytest 配置 |
|
|
|
元数据 |
|
这章的核心就一句话:Python 项目的所有配置,先想"能不能塞进 pyproject.toml"。能塞就别再开新文件。
四、包、模块与导入机制
写项目就绕不开"导入"。这一章把 Python 导入机制讲清楚,让你以后看到 ImportError 不再两眼一抹黑。
4.1 先厘清三个概念:模块、包、命名空间包
很多人混用"模块"和"包",其实定义很明确:
- 模块(module):一个
.py文件就是一个模块。文件名即模块名。 - 包(package):一个包含
__init__.py的目录,里面可以放多个模块。包可以嵌套(子包)。 - 命名空间包(namespace package):Python 3.3+ 引入,允许一个包的逻辑内容跨多个目录甚至多个发行版,不需要
__init__.py。大型框架分拆时才用,新手用不到,知道有这么个东西即可。
举个具体例子:
my_project/
├── core/ # 这是一个"包"(有 __init__.py)
│ ├── __init__.py
│ ├── models.py # 这是一个"模块"
│ └── utils.py # 这也是一个"模块"
└── api/
├── __init__.py
└── routes.py
4.2 导入机制怎么工作
当你写下 import core.models 时,Python 不是凭空找到它的。它走的是这套流程:
第一,查 sys.path。 这是一个路径列表,Python 依次在这些目录里找你导入的名字。你可以打印看看:
import sys
print(sys.path)
# ['', '/usr/lib/python313.zip', '/usr/lib/python3.13', ..., '/你的项目/.venv/lib/python3.13/site-packages']
第一个空字符串 '' 表示"当前目录"——这就是为什么你在项目根目录跑脚本时,能直接 import core。但如果你从别的目录跑,当前目录变了,可能就 import 不到了,这是新手最常见的坑之一。
生活化类比:导入机制像图书馆借书系统。sys.path 是"索书路线单"——系统按单子上的书架顺序一个个找,找到就停,找不到就报 ModuleNotFoundError。你想借的书(模块)如果没登记在路线单的任何书架上,系统就告诉你"查无此书"。
第二,遇到包目录,读 __init__.py。 它有两个作用:
- 标记"这是个包"(传统包的标志,虽然 3.3+ 没它也能成命名空间包,但显式写上更清晰)。
- 包初始化代码:可以在这里提前导入子模块,让外部用
from core import models而不必写from core import models(实际等价,但可控制暴露什么)。
第三,绝对导入 vs 相对导入。
# 绝对导入:从顶层包写完整路径,清晰、推荐
from core.models import User
from core.utils import helper
# 相对导入:用 . 表示当前包,.. 表示上级包,只在包内部用
from .models import User # 当前包的 models
from ..api.routes import api # 上一级包的 api.routes
经验法则:包内部模块互相引用可以用相对导入(重构改包名时不用改导入);对外/顶层脚本用绝对导入更稳妥。新手优先全部用绝对导入,少踩坑。
4.3 包结构关系图
下面这张图展示一个典型多层级包的导入依赖关系(箭头表示"依赖于/导入了"):
看这张图能直观理解:main.py 是入口,依赖 core 和 api 两个包;routes.py 用相对导入(..core)回到上级引用 core.models;__init__.py 控制对外暴露哪些模块。
4.4 两大经典报错拆解
报错一:ModuleNotFoundError / ImportError
绝大多数情况是这两类原因:
# 错误场景:从子目录直接运行脚本,sys.path 里没有项目根
# 你在 my_project/core/ 下执行 python services.py
from .models import User # 报错:attempted relative import with no known parent package
根因:sys.path 不对,或运行方式不对。解法:
# 正确:从项目根目录用模块方式运行
python -m core.services # 推荐
# 或安装自己的包后直接用
uv pip install -e . # 以"可编辑"模式安装,全局可见
报错二:循环导入(circular import)
a.py 里 from b import x,b.py 里又 from a import y——导入时互相等待,直接崩。
# 循环导入
# a.py
from b import do_b
def do_a(): return do_b()
# b.py
from a import do_a # a 还没初始化完,拿不到 do_a
def do_b(): return do_a()
三种解法,按推荐顺序:
# 解法一:推迟导入(把 import 放到函数内部)
def do_b():
from a import do_a # 调用时才导入,避开了模块加载期的循环
return do_a()
# 解法二:重构,把共用逻辑抽到第三个模块 c.py
# a.py 和 b.py 都 from c import shared,a、b 之间不再互相依赖
# 解法三:只为类型注解时,用 TYPE_CHECKING
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from a import do_a # 运行时不导入,只给类型检查器看
4.5 常见导入报错速查表
|
报错信息 |
根因 |
解法 |
|
|
模块不在 |
检查运行目录;或 |
|
|
直接运行了包内脚本,没有父包上下文 |
改用 |
|
|
循环导入导致名字还没定义 |
推迟导入 / 重构 / |
|
导入成功但行为怪异 |
同名模块遮蔽(如把文件命名为 |
改名,避开标准库名 |
这章是编码排错的高发区。记住排查三连:看运行目录(sys.path 对不对)→ 看包结构(有没有 __init__.py)→ 看依赖方向(有没有环)。
五、Python 项目目录结构
最后一章,把前面所有东西"装"进一个合理的目录结构里。好的结构让你导入顺手、测试好写、协作不乱;坏的结构会让你每加一个功能都举步维艰。
5.1 两种主流布局:flat vs src
历史上项目有两种布局:
扁平布局(flat layout):源码直接放根目录。
my_project/
├── core/ # 源码包就在根
├── api/
├── tests/
└── pyproject.toml
src 布局(src layout):源码统一放进 src/ 目录。
my_project/
├── src/
│ └── core/ # 源码包在 src 下
│ └── api/
├── tests/
└── pyproject.toml
生产推荐 src 布局。为什么?因为它有一个关键好处:强制你以"安装后"的方式测试。
扁平布局的坑是:你在项目根目录跑测试时,Python 把当前目录加进 sys.path,于是 import core 直接命中了本地源码文件夹,而不是"安装版本"。这意味着:
- 你可能"测试一直通过",但发布出去后别人装你的包却 import 不到(因为打包配置漏了文件);
- 你改了源码,本地测试用的是改过的,跟发布的不一致。
src 布局把源码挪到 src/ 下,根目录不再自动命中源码,你必须先把包装好(uv pip install -e .)才能 import——这一步就逼着你把打包配置弄对。问题暴露在开发期,而不是发布后。
生活化类比:目录结构像房子的户型图。动线合理(导入路径短、清晰)、功能区划清(测试/文档/源码各归其位),住着才不乱。把所有 .py 堆根目录,就像把卧室厨房书房全塞一间屋——开始能住,越住越崩。
5.2 一个生产级目录结构(推荐模板)
下面这套结构是经过大量项目验证的通用模板,中小型项目可以直接套:
my_project/
├── src/ # 源码区(src 布局)
│ └── my_project/ # 主包,名字 = pyproject.toml 里的 name
│ ├── __init__.py # 包初始化,可暴露版本号等
│ ├── core/ # 核心业务逻辑
│ │ ├── __init__.py
│ │ ├── models.py # 数据模型
│ │ └── services.py # 业务服务
│ ├── api/ # 对外接口(如 Web 路由)
│ │ ├── __init__.py
│ │ └── routes.py
│ ├── cli.py # 命令行入口
│ └── __main__.py # 支持 python -m my_project 运行
│
├── tests/ # 测试区(与 src 平级)
│ ├── conftest.py # pytest 公共 fixture
│ ├── unit/ # 单元测试
│ └── integration/ # 集成测试
│
├── docs/ # 文档区
│ ├── index.md
│ └── ...
│
├── scripts/ # 运维/部署脚本(非源码)
│ └── deploy.sh
│
├── .github/ # CI/CD 配置
│ └── workflows/
│
├── pyproject.toml # 【配置中枢】元数据 + 依赖 + 工具配置
├── uv.lock # 【锁文件】精确锁定依赖,必须提交
├── README.md # 项目说明
├── .gitignore # 忽略 .venv/、__pycache__/、dist/ 等
└── .python-version # (可选)声明项目用的 Python 版本
各部分职责一目了然,几个关键约定强调一下:
- 主包名 = 项目名:
src/下那个目录名要和pyproject.toml的name一致,否则装完 import 不到。 tests/与src/平级:测试不在源码包里,它只是"消费者"。tests/里不需要__init__.py(现代 pytest 推荐做法)。- 根目录只放配置文件:
pyproject.toml、uv.lock、README.md、.gitignore等,不要把业务.py丢根目录。
5.3 项目目录组织图
用一张图把结构分层展示,方便整体记忆:
5.4 可选目录与常见坏结构
可选目录按需添加,不要一开始就堆:
|
可选目录 |
何时加 |
建议 |
|
|
有测试用的小数据集 |
大数据用 DVC/对象存储,别进 Git |
|
|
数据探索、Jupyter |
探索用,产出迁回 |
|
|
多环境配置(dev/prod) |
也可放 |
|
|
给用户的示例 |
库项目常用 |
常见坏结构(看到就改):
- 所有
.py平铺在根目录 → 改成src/包名/结构。 - 测试和源码混在一起(
core/test_models.py)→ 测试挪到独立tests/。 - 用中文/拼音目录名(
业务逻辑/、gongju/)→ 全英文,蛇形命名。 - 没有
.gitignore,.venv/和__pycache__/全提交 → 用uv init自动生成的模板。
这章是"项目重构"的核心。如果你手头有个乱项目,照着 5.2 的模板慢慢挪,比推倒重写靠谱得多。
六、总结:一条主线,串起五件套
如果你要从零搭一个新项目,最短上手顺序就是这条主线本身:
- 环境检查:确认 Python 版本(3.12/3.13),别用系统自带;
- 实际建项目:
uv init,跑通uv add → uv sync; - 配置实践:完善
pyproject.toml(元数据、依赖、工具配置); - 编码排错:在
src/下组织包,遇到ImportError用第四章三连排查; - 项目重构:对照第五章模板,把结构理顺。
工程化的本质,其实就是把"能跑"变成"能稳定地跑、能协作地跑、能长期地跑"。这套基本功看似琐碎,但一旦内化,你写 Python 的体感会从"每次都在救火"变成"一开始就不会着火"。这五件套,是这条路上绕不开的地基。共勉。
更多推荐



所有评论(0)