Python 中的虚拟环境、pip 和 pyproject.toml
1. 概述
当在同一台机器上做多个 Python 项目时,你很快就会遇到版本冲突。项目 A 需要 requests 2.28,项目 B 则需要 requests 2.32。这篇文章介绍三个工具来解决这个问题。venv 为每个项目隔离环境。pip 安装和管理 package。pyproject.toml 按照现代标准声明项目。读完这篇文章,你将能够构建一个结构规范的 Python 项目,以 editable 模式安装,并用 pytest 运行测试。
2. 虚拟环境
虚拟环境(venv)是一个包含三样东西的目录:一个独立的 Python 解释器(通常是符号链接)、一个独立的 site-packages 目录和激活脚本。你在 venv 中安装的 package 只会存在于该目录中,不会影响系统 Python。
2.1. 创建和激活 venv
从 Python 3.3 开始,venv 模块已包含在标准库中,所以你不需要额外安装任何东西:
mkdir shopcart && cd shopcart
python3 -m venv .venv
# Linux / macOS
source .venv/bin/activate
# Windows (PowerShell)
.venv\Scripts\Activate.ps1
命令 python3 -m venv .venv 在项目中创建 .venv 目录。命名为 .venv 是常见约定,大多数 IDE 都会自动识别它。激活后,shell 提示符会有 (.venv) 前缀。此时命令 python 和 pip 会指向 venv。
要退出 venv,你可以运行:
deactivate
2.2. 检查你在使用哪个解释器
这是许多人会跳过的步骤,它也是许多“已安装但仍出现 ModuleNotFoundError“错误的根源:
which python
python -c "import sys; print(sys.prefix); print(sys.prefix != sys.base_prefix)"
/home/lan/shopcart/.venv/bin/python
/home/lan/shopcart/.venv
True
sys.prefix 是当前运行环境的根目录。sys.base_prefix 是原始 Python 的根目录。两个值不同意味着你在 venv 中。
注意: 不要将
.venv目录 commit 到 Git。请在.gitignore中添加.venv/。Venv 依赖于绝对路径和操作系统,复制到其他机器通常会损坏。正确的做法是 commit 依赖声明文件,然后在新机器上重新创建 venv。
3. 用 pip 管理 package
pip 是默认的 package 安装工具,从 PyPI 获取 package。
3.1. 基本命令
python -m pip install --upgrade pip
python -m pip install "requests>=2.31,<3"
python -m pip list
python -m pip show requests
python -m pip uninstall -y requests
各个命令的作用如下:
install --upgrade pip升级 pip 本身。旧版本 pip 有时无法正确读取pyproject.toml。"requests>=2.31,<3"是一个版本说明符。你需要用引号括起来,防止 shell 将<理解为重定向。list列出已安装的 package。show打印详细信息,包括版本、安装位置和依赖的 package。
3.2. 为什么要用 python -m pip 而不是 pip
命令 pip 可能指向与你使用的 python 不同的 Python。这种情况常发生在机器有多个 Python 版本,或你忘记激活 venv 的时候。命令 python -m pip 总是运行该解释器的 pip,所以 package 肯定会被安装到你想要的环境中。
3.3. requirements.txt 和 pip freeze
分享 package 列表的传统方式是使用 requirements.txt:
python -m pip freeze > requirements.txt
python -m pip install -r requirements.txt
certifi==2024.8.30
charset-normalizer==3.4.0
idna==3.10
requests==2.32.3
urllib3==2.2.3
pip freeze 记录 venv 中的所有 package 及其精确版本,包括间接依赖(如 urllib3)。这种方式适合重现运行环境。但是,它不能区分你实际需要哪些 package 和哪些只是被拉进来的。它也没有描述你的项目:名称、版本、Python 要求。这是 pyproject.toml 的工作。
4. 用 pyproject.toml 声明项目
pyproject.toml 是 Python 项目的标准配置文件。它在 PEP 518、PEP 621 及相关 PEP 中定义。这个文件取代了 setup.py 和 setup.cfg,并将 pytest、ruff 等许多工具的配置集中在一个地方。
4.1. 项目结构
我们使用 src layout,即将代码放在 src/ 中。这种布局确保测试始终运行在已安装的 package 上,而不是意外地直接从工作目录导入:
shopcart/
├── pyproject.toml
├── src/
│ └── shopcart/
│ ├── __init__.py
│ ├── order.py
│ └── cli.py
└── tests/
└── test_order.py
4.2. 完整的 pyproject.toml 文件
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "shopcart"
version = "0.1.0"
description = "简单的订单计费"
requires-python = ">=3.11"
dependencies = [
"requests>=2.31,<3",
]
[project.optional-dependencies]
dev = [
"pytest>=8",
"ruff>=0.5",
]
[project.scripts]
shopcart = "shopcart.cli:main"
[tool.pytest.ini_options]
testpaths = ["tests"]
各部分的含义如下:
[build-system]告诉 pip 需要哪些工具来构建项目。这里是 setuptools。你可以替换为hatchling或flit_core,无需修改其余部分。[project]包含标准元数据。requires-python防止他人在不支持的 Python 版本上安装项目。dependencies是运行项目所需的必需 package。这里你应该声明版本范围(>=2.31,<3),而不是固定版本(==2.32.3)。[project.optional-dependencies]分组可选 package。dev组包含最终用户不需要的开发工具。[project.scripts]在终端上创建shopcart命令。该命令调用shopcart.cli模块中的main函数。[tool.pytest.ini_options]是 pytest 的专属配置。每个工具都有自己的[tool.<名称>]命名空间。
使用 src layout,setuptools 会自动在 src/ 中查找 package,所以你不需要额外声明。
4.3. Package 代码
# src/shopcart/order.py
from dataclasses import dataclass, field
from decimal import Decimal
@dataclass(frozen=True)
class OrderLine:
product: str
unit_price: Decimal
quantity: int
@dataclass
class Order:
customer: str
lines: list[OrderLine] = field(default_factory=list)
def add(self, product: str, unit_price: str, quantity: int = 1) -> None:
if quantity <= 0:
raise ValueError("quantity 必须大于 0")
self.lines.append(OrderLine(product, Decimal(unit_price), quantity))
def total(self) -> Decimal:
return sum((l.unit_price * l.quantity for l in self.lines), Decimal("0"))
Order 保存 OrderLine 列表。价格使用 Decimal 以避免浮点误差,因此 unit_price 以字符串形式传入。total() 方法对每一行的金额求和,初值为 Decimal("0")。
# src/shopcart/cli.py
from shopcart.order import Order
def main() -> None:
order = Order(customer="阮文安")
order.add("咖啡奶", "29000", 2)
order.add("法棍面包", "25000")
print(f"顾客: {order.customer}")
print(f"总价: {order.total():,} VND")
文件 src/shopcart/__init__.py 可以留空。
4.4. 以 editable 模式安装
python -m pip install -e ".[dev]"
shopcart
顾客: 阮文安
总价: 83,000 VND
标志 -e(editable)将 package 链接到源代码,而不是复制到 site-packages。这样,当你修改 order.py 时,更改立即生效,无需重新安装。部分 ".[dev]" 表示在当前目录中安装项目以及 dev 组,所以 pytest 和 ruff 也会被安装。由于 [project.scripts] 中的声明,命令 shopcart 在终端中可用。
注意: 某些 shell(如 zsh)会将
[...]理解为 glob 模式。因此你应该始终将".[dev]"用引号括起来。
5. 编写并运行测试
# tests/test_order.py
from decimal import Decimal
import pytest
from shopcart.order import Order
def test_total_sums_all_lines():
order = Order(customer="陈思平")
order.add("桃花茶", "35000", 2)
order.add("焦糖布丁", "15000", 3)
assert order.total() == Decimal("115000")
def test_empty_order_has_zero_total():
assert Order(customer="李明").total() == Decimal("0")
def test_add_rejects_non_positive_quantity():
order = Order(customer="范河")
with pytest.raises(ValueError, match="quantity"):
order.add("矿泉水", "10000", 0)
python -m pytest -q
... [100%]
3 passed in 0.02s
测试将 shopcart.order 作为已安装的 package 导入。这是你需要上面的 pip install -e 步骤的原因。如果未安装,src layout 会报告 ModuleNotFoundError,这个错误是有意的:它强制你测试用户会安装的东西。第三个测试使用 pytest.raises 来检查无效的数量是否被拒绝,错误消息中包含“quantity”。
6. 版本锁定:pyproject.toml 还是 requirements.txt?
这两个文件不是相互排斥的。它们服务于两个不同的目的:
pyproject.toml |
Lock 文件(固定版本的 requirements.txt) |
|
|---|---|---|
| 目的 | 描述项目需要什么 | 精确重现环境 |
| 版本 | 范围(>=2.31,<3) |
固定(==2.32.3) |
| 适用于 | 库和应用程序 | 部署应用程序(服务器、Docker) |
对于要发布到 PyPI 的库,你只需要 pyproject.toml。如果固定版本,你的库的用户会遇到冲突。
对于在服务器上运行的应用程序,你应该有一个 lock 文件,以便每次部署都安装相同的一组版本:
python -m pip install -e .
python -m pip freeze --exclude-editable > requirements.lock
部署时,先从 lock 文件安装,然后再安装项目,以防 pip 自动升级依赖版本:
python -m pip install -r requirements.lock
python -m pip install --no-deps .
如果想要更简洁的 lock 过程,你可以使用 pip-tools(命令 pip-compile pyproject.toml)或 uv。两者都直接读取 pyproject.toml 并生成带有哈希的 lock 文件。
7. 常见错误
- 用
sudo pip install安装 package。这个命令会覆盖操作系统的 package,可能会破坏系统工具。许多新的 Linux 发行版甚至会直接阻止,提示externally-managed-environment错误。解决方法是始终使用 venv。 - 忘记在安装或运行前激活 venv。你可以用
which python检查(如第 2.2 节所示),或直接调用.venv/bin/python -m pip ...。 - 用一个 Python 创建 venv,用另一个 Python 运行。Venv 紧密绑定到创建它的解释器。当你升级 Python(例如从 3.11 到 3.12)时,删除
.venv并重新创建。 - 移动或重命名项目目录。
.venv/bin中的脚本包含绝对路径,移动后会损坏。你需要重新创建 venv。 - 混合旧的
setup.py和pyproject.toml并在两个地方声明依赖。选择唯一的真实源。对于新项目,那是[project]表。 - 将开发工具放入
dependencies。pytest或ruff应该在optional-dependencies中(或者[dependency-groups]如果你的工具支持的话),这样最终用户就不必安装它们。
注意: 当你只需要在整个机器上运行独立的 CLI 工具(如
black或httpie)时,不应该使用 venv。在这种情况下,pipx更合适:它为每个工具自动创建一个单独的 venv,并将命令放入PATH。
8. 总结
这篇文章介绍了 Python 项目的三个基础工具:
python -m venv .venv为每个项目隔离依赖。python -m pip将 package 安装到正在使用的解释器。pyproject.toml在单个文件中声明元数据、依赖、可选组、CLI 命令和工具配置。
结合 src layout、pip install -e ".[dev]" 和 pytest,你有一个干净、易于测试和易于共享的项目。对于需要稳定部署的应用程序,在 pyproject.toml 旁边添加一个 lock 文件,以确保每个环境都安装相同的一组版本。