← 返回模块
3.1.3.1beta 可读 · 未来免费内容校验中内容版本 2026-05-27

pyproject.toml 与虚拟环境

3.1.3 · Python 打包与测试 · 编程

周三晚上九点,你在一家上海私募的策略组里,把白天调好的 A 股因子算子打包发给同事,让他在另一台机器上跑同样的回测。他 git clone 完,进到目录里直接 python main.py,立刻就崩了:ModuleNotFoundError: No module named 'xyzprice'。你叫他先 cd src && python main.py,他换了路径又报 numpy: installed 1.24, code requires 1.26。两个人就这样在企业微信上拉锯到十一点。

这一类问题不是「能力问题」,是「打包问题」。你交付给他的是一堆 .py 文件,而不是一个​​包​​(package)。这一课要做的,就是把这堆 .py 文件升级成一个标准 Python 项目:写一份 pyproject.toml、建一个虚拟环境(virtual environment)、用「可编辑安装」(editable install)把项目装进环境,最后用锁文件(lockfile)把这台机器上的精确版本固定下来。

1. 文件布局:src-layout 还是 flat-layout

最常见的两种布局,都从仓库根开始算起。

  • ​flat-layout​​:根目录下直接放 xyzprice/__init__.py 与配套的 tests/。写脚本时直接 import xyzprice 就能用,开箱即用。
  • ​src-layout​​:在根目录下额外加一层 src/,包真正落在 src/xyzprice/__init__.pyimport xyzprice 必须先把项目装到环境里才生效。

对内部一次性脚本,flat-layout 更顺手;对要分享的包,推荐 src-layout——它在第一时间就逼你完成「装包」这一步,从而暴露所有在 flat-layout 下被工作目录意外掩盖的导入问题。本课全程使用 src-layout。

2. 第一张表:[build-system]

pyproject.toml 是 PEP 517 / PEP 518 之后整个 Python 打包世界的入口文件。它的第一张表回答一个问题:​​用什么后端把源码变成 wheel?​

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

这两行解耦了「构建后端」(build backend,本例为 hatchling)与「构建前端」(build frontend,通常是 pipuv)。前端只负责调度,后端真正读取源码并产出 wheel。常见后端有 hatchlingsetuptoolsuv_build,Poetry 也是另一种常见选择。把后端写在 [build-system] 而不是硬编码进 pip 里,是因为 PEP 517 让任何前端能构建任何项目——你换用 uv 安装时,不需要改一行 pyproject.toml

3. 第二张表:[project]

第二张表回答另一个问题:​​这是什么项目?依赖哪些第三方库?​

[project]
name = "xyzprice"
version = "0.1.0"
description = "A 股价格工具"
requires-python = ">=3.10"
readme = "README.md"
dependencies = ["numpy>=1.26", "pandas>=2.1"]

nameversion 是 PEP 621 强制要求的两个字段;descriptionrequires-pythonreadme 是惯例上必填的三项。dependencies 列表里的字符串遵循 PEP 440 的版本约束(version specifier)语法:>= 表示「不低于」,< 表示「低于」,== 表示「精确等于」,~= 表示「兼容版本」(major 不变),!= 表示「排除」。其余写法请翻阅 PEP 440 原文。

A 股配套的小工具 src/xyzprice/__init__.py 里暴露一个 mean_price(ticks) 函数,输入是若干 Tick(code: str, price: float, volume: int) 记录,例如 Tick("XYZ001.SH", 1850.0, 100)Tick("ABC002.SZ", 12.5, 1000)。这些数据结构本身不是本课重点,本课把 xyzprice 当作黑盒——你只需要知道它依赖 numpypandas

4. 开发依赖:[project.optional-dependencies]

运行时依赖(runtime dependencies)已经写在 dependencies 列表里。但 pytestruffmypy 这些工具不需要被项目的最终用户装上,它们只服务于开发流程。把它们写进一张可选依赖表:

[project.optional-dependencies]
dev = ["pytest", "ruff", "mypy"]

用户装 pip install xyzprice 时只拿到 numpypandas;开发者装 pip install -e ".[dev]" 时再额外加上 pytestruffmypy

PEP 735 引入了更现代的 [dependency-groups] 表来表达同样的语义,对 linttestdocs 等多个独立群组的项目更友好;本课暂用 [project.optional-dependencies],因为它已被全部主流前端支持。

5. 虚拟环境与可编辑安装

依赖装到全局解释器里,迟早会和别的项目打架。​​虚拟环境​​(virtual environment)把每个项目隔离在自己的 .venv/ 目录里。最朴素的做法用标准库的 venv 模块:

python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"

最后一行的 -e 表示​​可编辑安装​​(editable install, PEP 660):环境里的 import xyzprice 直接指向源码树里的文件,你改一行 src/xyzprice/__init__.py,下次解释器重新导入时就能看到改动,​**​不需要重新 pip install**​。.[dev] 里的方括号语法把刚才那张 dev 可选依赖表也一并装上。Windows 用户把 source 那一行换成 .venv\Scripts\activate

uv 是新一代的安装与解析器,比 pip 在常见工作流上快 10 到 100 倍。同样四步:

uv venv
source .venv/bin/activate
uv pip install -e ".[dev]"
uv lock

uv 可以通过 pip install uv 装进现有解释器,也可以从官网用一行 shell 命令独立安装。在国内,你可以用 pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ 或者清华 TUNA 镜像把下载源换到本地,这只是网络优化,与 PEP 标准无关。

6. 锁文件:抽象依赖 vs 具体依赖

pyproject.toml 里写的是项目「​​兼容​​」哪些版本;锁文件(lockfile)里写的是某台机器上某一刻「​​真正装到了​​」哪个版本。

一句话区分:"numpy>=1.26" 写在 pyproject.toml 里,是 abstract(抽象)兼容范围;numpy==1.26.4 写在 uv.lockrequirements.txt 里,是 concrete(具体)已解析版本。

最朴素的锁文件由 pip freeze > requirements.txt 产出——它把当前环境所有已装包按 == 精确版本导出。uv lock 则在更深的层次工作:它额外记录依赖图、解析器哈希、平台标记,是 uv 推荐的方案。

抽象依赖与具体依赖分工的原因是:项目作者不知道未来 numpy 会发布多少次小版本,但能保证自己的代码与 1.26 及以上兼容;与此同时,每一次 CI 构建、每一次生产部署,又必须读取同一组精确版本,否则今晚跑通的回测,明早就可能因为 pandas 静默升级到 2.2 而对不上账。

练习

Exercise

给定文件夹 myproj/,其中 src/myproj/__init__.py 定义了一个函数 def hello() -> str: return "hi"。请编写一份最小的 pyproject.toml,满足以下条件:(1) 使用 hatchling 作为构建后端;(2) 项目名 myproj,版本号 0.1.0;(3) 声明 requires-python = ">=3.10";(4) 仅列出 numpy>=1.26 一项运行时依赖;(5) 声明一个 dev 可选依赖组,仅包含 pytest。然后写出三条 shell 命令:创建虚拟环境、激活、并以可编辑模式连同 dev 附加依赖一并安装。

提示
先把 [build-system] 写好:requires = ["hatchling"]build-backend = "hatchling.build";再写 [project] 块,注意 versionrequires-python 都是字符串,dependencies 是字符串列表。
提示
三条命令依次是 python -m venv .venvsource .venv/bin/activatepip install -e ".[dev]"。最后一条里的引号与方括号必须按字面写出,shell 不会自动展开它们。

下一课会切换视角:你已经把 xyzprice 装进了虚拟环境,但还没有任何一行测试在守护它的行为。下一课「pytest 基础」会带你写第一组测试,覆盖 mean_price 在空列表、单条记录、多条记录三种输入下的预期返回,并解释为什么 pytest 早已被列在你刚才那张 dev 表里。