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 文件,以确保每个环境都安装相同的一组版本。

pythonpipvenvpyprojectpackaging