把「在我电脑上能跑」变成「在哪都能跑」:虚拟环境隔离依赖,
pyproject.toml声明真相,锁文件保证重现。
你将学到
- 为什么必须用虚拟环境,以及
venv的正确用法 pip+requirements.txt的传统工作流及其缺陷pyproject.toml与现代打包规范(PEP 517/621)poetry与uv的定位对比和常用命令- 依赖版本约束语义(
~=、>=、==)与锁文件 - 发布到 PyPI 的流程,以及
src layout项目结构最佳实践
前置知识
先读 上一篇:类型注解与 mypy,本文的示例会用到类型注解。
一、为什么需要虚拟环境
Python 的包默认装到「全局环境」。一旦多个项目依赖同一个包的不同版本,就冲突了:
# 全局装 Django 4.x,另一个老项目需要 3.x —— 二者不可能共存
pip install django==4.2
venv 给每个项目一个独立的 site-packages,互不干扰。
# 在当前目录创建名为 .venv 的虚拟环境
python -m venv .venv
# 激活(Windows Git Bash / PowerShell / Linux 各有不同)
source .venv/Scripts/activate # Windows Git Bash
.venv\Scripts\Activate.ps1 # Windows PowerShell
source .venv/bin/activate # macOS / Linux
# 激活后,python 和 pip 都指向这个环境
which python # 输出: .../项目/.venv/Scripts/python
python -m pip install requests
# 退出
deactivate
永远用 python -m pip 而不是裸 pip——前者保证用的是当前解释器的 pip,能避免装错环境的经典事故。
python -m pip install --upgrade pip # 顺手升级 pip 自身
二、pip + requirements.txt
最朴素的工作流:导出当前依赖清单,别人照着装。
pip freeze > requirements.txt # 导出(包含所有间接依赖)
pip install -r requirements.txt # 安装
requirements.txt 长这样:
requests==2.31.0
flask>=3.0,<4.0
它的缺陷很明确:
- 不分直接依赖和间接依赖:
pip freeze会把整棵依赖树平铺出来,你无法判断哪些是自己想要的。 - 没有锁文件语义:不同时间解析出的间接依赖版本可能不同,重现性差。
- 环境与打包混为一谈:它描述"这个环境装了啥",不描述"这个包本身依赖啥"。
requirements.txt 依然适合部署场景(生成一份冻结清单),但不适合描述项目元数据。
三、pyproject.toml 与现代打包
PEP 518/517 引入了 pyproject.toml 作为构建配置的统一入口,PEP 621 规定了项目元数据的标准字段。
[build-system]
requires = ["hatchling"] # 构建后端
build-backend = "hatchling.build"
[project]
name = "my-tool"
version = "0.1.0"
description = "一个示例工具"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
authors = [{ name = "Ada", email = "ada@example.com" }]
# 直接依赖(运行所需)
dependencies = [
"requests>=2.31",
"click~=8.1",
]
# 可选依赖,按场景分组
[project.optional-dependencies]
dev = ["pytest>=8.0", "mypy", "ruff"]
docs = ["sphinx"]
# 命令行入口:my-tool 命令对应 my_tool.cli:main
[project.scripts]
my-tool = "my_tool.cli:main"
装上构建后端后就能构建和发布:
python -m pip install build
python -m build # 生成 dist/*.whl 和 dist/*.tar.gz
# 输出: Successfully built my_tool-0.1.0.tar.gz and my_tool-0.1.0-py3-none-any.whl
pyproject.toml 现在是唯一正确的项目配置中心:依赖、构建、工具链(mypy/ruff/pytest)都往里塞。
四、poetry 与 uv
poetry:老牌一体化方案
pipx install poetry # 推荐用 pipx 独立安装
poetry new my-project # 脚手架
poetry init # 已有项目里互动式生成配置
poetry add requests # 添加依赖,自动写 pyproject 并更新锁文件
poetry add --group dev pytest
poetry install # 按 poetry.lock 精确安装
poetry run pytest # 在项目环境里执行命令
poetry lock # 只更新锁文件
poetry build # 构建发行包
poetry.lock 记录每个包的确切版本和哈希,保证「同样的锁文件装出同样的环境」。
uv:Rust 写的新一代工具
uv 用 Rust 实现,安装解析速度比 pip 快一到两个数量级,且兼容 pip 工作流。
# 安装(独立二进制)
curl -LsSf | sh
uv venv # 创建 .venv
uv pip install requests # 兼容 pip 语义,但极快
uv pip install -r requirements.txt
# 自带的项目管理(对标 poetry)
uv init
uv add requests # 写入 pyproject.toml + uv.lock
uv add --dev pytest
uv sync # 按 uv.lock 同步环境
uv run pytest
uv lock
怎么选
| 维度 | poetry | uv |
|---|---|---|
| 速度 | 一般 | 极快 |
| 生态成熟度 | 高,历史项目多 | 新,但发展迅猛 |
| pip 兼容 | 自己的解析器 | 可直接当 pip 用 |
| 适用 | 团队已有 poetry 习惯 | 新项目、追求速度 |
建议:新项目直接上 uv(顺便还能当 pip 用);老项目沿用 poetry 即可,别为了赶时髦迁移。
五、版本约束与锁文件
版本约束写法(PEP 440):
==1.2.3 # 精确锁定
>=1.2,<2.0 # 区间
~=1.4.2 # 兼容发布:等价于 >=1.4.2, <1.5.0
~=1.4 # 等价于 >=1.4, <2.0
!=1.5.0 # 排除某版本
~= 最容易被误解,记住它锁定的是倒数第二段:~=1.4.2 允许到 1.4.x,~=1.4 允许到 1.x。
区分两个文件:
pyproject.toml的dependencies:写范围,表达能力与意图(人读的)。- 锁文件(
poetry.lock/uv.lock):写确切版本,保证可重现(机器用的)。
两者都要提交到版本控制——前者决定「允许什么」,后者决定「这次用什么」。
六、发布到 PyPI
pip install build twine
python -m build # 生成 dist/
twine check dist/* # 检查元数据合法性
# 先传到测试仓库验证
twine upload --repository testpypi dist/*
# 没问题再传正式仓库(需要 PyPI API Token)
twine upload dist/*
用 Token 而不是账号密码,配在 ~/.pypirc 或环境变量里:
export TWINE_USERNAME=__token__
export TWINE_PASSWORD=pypi-AgEIcHlwaS5vcmc...
注意 name 在 PyPI 上全局唯一,先想好名字。版本号一旦上传不可覆盖,只能发新版本。
七、项目结构:src layout
推荐的目录结构:
my-project/
├── pyproject.toml
├── README.md
├── LICENSE
├── .gitignore
├── src/
│ └── my_tool/ # 包名 = 导入名
│ ├── __init__.py
│ ├── cli.py
│ └── core.py
└── tests/
├── test_core.py
└── conftest.py
为什么用 src/ 而不是把包放根目录:
- 强制「必须先安装才能导入」,避免测试时误用当前目录的源码而掩盖打包问题。
- 测试与源码物理分离,导入路径干净。
包名用小写下划线(my_tool),项目名用连字符(my-project),PyPI 上二者等价但项目名更友好。
常见坑
坑 1:忘了激活环境,装到全局去了
# ❌ 忘记激活,requests 装进了系统 Python
pip install requests
# ✅ 先激活,再用 python -m pip 双重保险
source .venv/bin/activate
python -m pip install requests # 确定装进 .venv
坑 2:把 .venv 提交进 git
# .gitignore
.venv/
__pycache__/
*.egg-info/
dist/
build/
.venv 是机器相关的二进制目录,绝不能进版本控制。锁文件反而必须提交。
坑 3:requirements.txt 与 pyproject 依赖打架
# ❌ 两处各写一份依赖,时间一长必然不一致
# pyproject.toml 里写 requests>=2.31
# requirements.txt 里又写 requests==2.28.0
# ✅ pyproject 作为唯一真相源,requirements 由它导出
# uv: uv export --format requirements-txt > requirements.txt
# poetry: poetry export -f requirements.txt --output requirements.txt
小结
- 每个项目一个虚拟环境,用
python -m venv .venv创建,python -m pip安装。 requirements.txt适合导出部署清单,不适合描述项目元数据。pyproject.toml是现代项目配置中心(PEP 621),依赖、构建、工具链都在这里。poetry成熟、uv快;新项目推荐 uv,它还能直接当 pip 用。- 依赖范围写进
pyproject.toml,确切版本写进锁文件,两者都提交。 - 发布用
build+twine,版本号不可覆盖。 - 用
src/layout,测试与源码分离,强迫走「安装后导入」。
延伸阅读
- PEP 517 / 518 / 621 / 440 —— 构建系统与版本规范
- uv 官方文档
- poetry 官方文档
上一篇:类型注解与 mypy · 下一篇:测试 pytest 与 TDD
文章回复
0 条公开回复