做Plone开发的人对zc.buildout不会陌生,但affinitic.recipe.fakezope2eggs这个包,哪怕在Plone圈子里也不算热门。我第一次跟它正面对上,是在给一个维护了七八年的老产品重建开发环境的时候。项目里塞满Products.*这种老牌Zope 2产品,而它们声明依赖的Zope2本体有一大串间接依赖链,有一半在Python 2.7和当前pip源里根本装不回来。当时有同事提了一句“做个fake egg骗过依赖检查”,我第一反应是这操作太hacker了,能靠谱吗?后来自己动手试了一圈才明白,这个recipe干的事其实非常朴素:它不安装任何真实代码,只是在构建环境里生成一批带正确元数据的空egg,用来满足zc.buildout在依赖解析阶段的硬性检查。这篇文章我就把这个包的语法、参数和一套完整可复现的实际应用案例拆开讲清楚,帮同样被Zope 2依赖链卡住的人少走点弯路。
1. 先搞清楚:一个buildout recipe去“伪造”依赖,到底在图什么
1.1 Zope 2依赖链的真实痛点
落到实际场景里,问题从来不在Zope2这个名字本身,而在它身后的依赖图。一个典型的Plone 4.x自定义产品,setup.py里写着install_requires = ['Products.CMFPlone'],而Products.CMFPlone又会声明依赖Products.CMFCore、Products.ZCatalog、Zope2等等。zc.buildout解析依赖时,会逐个读取这些包的PKG-INFO里的Requires-Dist字段,然后在当前可用的distribution集合里寻找匹配。找不到,它就会尝试从index下载。听起来很正常,对吧?但在内网环境、离线CI、或者Python 2.7已经“过气”的今天,这个下载动作往往直接失败,而且失败信息极其隐晦,经常是某一个小依赖包的某个版本在源里消失,导致整个buildout中断。
更烦人的是,大部分时候我们根本不需要完整的Zope 2服务。做Plone插件开发,经常只需要跑zope.testrunner的单测、检查ZCML注册有没有写错、或者生成Sphinx文档。这些场景里,真正运行的应用服务器进程压根不会启动,Zope 2的ZServer、Products内建目录、各种页面模板引擎都不会被实例化。那为什么还要让buildout把一大坨永远用不到的真实Zope 2包下载下来?这就是fake egg思路的起点:在依赖解析阶段给一个“替代品”,只要让pkg_resources认为Zope2已经满足要求,后续真正干活时用不用得到,由测试代码自己说了算。
1.2 fakezope2eggs在构建链中的位置
affinitic.recipe.fakezope2eggs的出现,正是为了接管这个“伪造”过程。它本身是一个标准的zc.buildout recipe,注册在包入口点的zc.buildout.recipe分组里。在buildout配置里声明它之后,buildout会在安装该part时调用recipe的install()方法,由recipe负责生成一批“看起来像egg”的目录。
它和zc.recipe.egg这类正经的依赖安装recipe的最大区别是:zc.recipe.egg会去下载、安装、处理真实代码;而fakezope2eggs只做一件事——创建最小可用的fake distribution。你可以理解成办假证:证件上姓名、身份证号、照片都齐全,扫描系统能通过,但这个人实际上不存在。
那有人会问:为什么不直接在src/下建一个空的Zope2包目录?最开始我也这么干过,后来发现这条路走不通。zc.buildout的egg解析机制,本质上是让pkg_resources在一个特定的搜索路径集合里寻找Distribution对象。这个对象需要从egg目录里的EGG-INFO/PKG-INFO文件读取元数据,而不是单靠一个Python包目录里有__init__.py就能被识别。手工建个空包,除非再手动写全套EGG-INFO,否则pkg_resources.get_distribution('Zope2')照样报DistributionNotFound。这个recipe的价值就是自动化这些繁琐的元数据文件生成。
1.3 fake egg不是“空目录+改名”那么简单
一个能被pkg_resources认出来的distribution,至少需要三样东西:
- 一个以
包名-版本-py版本.egg命名的目录; - 目录内部有真实的Python包结构(哪怕是空的
__init__.py); - 目录内部有
EGG-INFO/PKG-INFO文件,里面包含Name和Version字段。
只有第三点做对了,pkg_resources.working_set在遍历sys.path时才可能构造出Distribution对象。除此之外,如果fake的包是一个namespace package,还需要处理__init__.py里的__path__扩展,否则后续import Products.CMFPlone时会出现No module named Products这种莫名其妙的错误。
理解了这层底层诉求,后面看它的语法参数就会特别顺。
2. 安装和首个配置:让一个假Zope 2出现在sys.path里
2.1 最小buildout.cfg长什么样
fakezope2eggs的使用方式,和大多数buildout recipe一样,核心是在[buildout]部分声明part,然后在该part里指定recipe和eggs。一个最小可运行的配置长这样:
[buildout] parts = fakezope2 find-links = http://dist.plone.org/release/4.3.20 [fakezope2] recipe = affinitic.recipe.fakezope2eggs eggs = Zope2这段配置的逻辑很简单:让buildout创建一个名为fakezope2的part,使用affinitic.recipe.fakezope2eggs这个recipe,并且把Zope2作为要伪装的包名传进去。find-links是给buildout一个下载recipe包本身的来源地址,因为在全新环境里,affinitic.recipe.fakezope2eggs还没安装到当前Python解释器的site-packages里,buildout需要能从一个索引或链接列表找到它。
第一次运行:
bin/buildoutbuildout会先读取配置,解析出需要的recipe包,下载依赖,然后执行fakezope2这个part的install()方法。整个过程中如果Zope2本身真实存在,并不会被下载安装——这正是我们想要的。在Python 2.7环境下,构建产物通常会出现在parts/fakezope2/Zope2-2.13.26-py2.7.egg这个路径。
2.2 执行buildout之后生成了什么
构建成功后,到parts/fakezope2/目录看一眼,你大概会看到类似这样的结构:
parts/fakezope2/ └── Zope2-2.13.26-py2.7.egg ├── EGG-INFO │ ├── PKG-INFO │ ├── requires.txt │ └── top_level.txt └── Zope2 └── __init__.pyEGG-INFO/PKG-INFO里会记录项目的真实名称和版本号,requires.txt在这一步通常是空的,top_level.txt写着Zope2。整个fake egg占用的磁盘空间非常小,可能不到1KB。
这里有一个非常容易误解的点:这个fake egg里没有任何Zope2的源代码模块,所以如果你试图直接import Zope2运行某个真实功能,一定会报ImportError。它存在的唯一目的,是为了让pkg_resources在检查依赖时,认为Zope2这个distribution已经存在于当前环境中。
2.3 怎么验证fake egg生效了
验证方式不是import Zope2,而是通过pkg_resources的接口来确认。打开bin/python(注意是buildout生成的解释器,不是系统Python,因为buildout已经构造好了sys.path),执行:
import pkg_resources dist = pkg_resources.get_distribution('Zope2') print(dist) print(dist.version)如果一切正常,你会看到类似输出:
Zope2 2.13.26 2.13.26这一步能通,说明pkg_resources已经把这个fake egg纳入了当前working_set。后续buildout在解析Products.CMFPlone的依赖时,检查到Requires-Dist: Zope2,会在working_set里找到这个Zope2 2.13.26,认为依赖已经得到满足,从而跳过真实下载安装。
注意,这一步验证的是“依赖解析层面”的通过,而不是“运行层面”的可用。这个概念区分清楚,后面用起来就不会对fake egg抱有不切实际的期待。
3. 参数详解:用表格和实测定清每个配置项
3.1 eggs:要伪装的包名列表
eggs是fakezope2eggs最核心、也是几乎必填的参数。它的值是一个或多个包名,用空格或换行分隔。遇到依赖链比较长的情况,可以直接把这个列表写成多行,方便在代码review时看清到底伪造了哪些包。
[fakezope2] recipe = affinitic.recipe.fakezope2eggs eggs = Zope2 Products.CMFCore Products.CMFPlone这里的eggs参数只负责接收包名列表,并不负责版本号。版本号的指定方式,和我下面要说的[versions]段强相关。实际测试中,这份列表里列出的所有包,都会被逐个生成对应的fake egg目录。
3.2 版本锁定:为什么必须写死
其实fakezope2eggs本身并不强制要求版本参数,真正让它“必须写版本”的,是zc.buildout的依赖解析机制。当Products.CMFPlone声明依赖Zope2>=2.13.0,<2.14时,pkg_resources会检查working_set里已有的Zope2版本是否满足这个约束。如果[versions]里没有锁定Zope2的版本,fake egg生成时可能默认采用某个固定版本,也可能从依赖约束里倒推一个,但这个过程并不可控,容易导致版本不匹配。
我的经验是在实际项目中,所有fake的包都要在[versions]里手动写上精确版本号,哪怕那个版本号是“抄”的。比如:
[buildout] versions = versions [versions] Zope2 = 2.13.26 Products.CMFPlone = 4.3.20这样行为是可预期的。我见过不少新手不写[versions],结果buildout要么报版本冲突,要么因为找不到满足约束的fake版本,最后把真实Zope 2又拉下来了,白白浪费时间。
3.3 几个不那么常用但偶尔救命的参数
除了主力参数eggs,fakezope2eggs还有一些选项,在不同版本里名字和行为略有差异。以下是我在多个项目里实际用过的配置项整理,结合了常见实践补充,具体以你安装版本的README为准。
| 参数名 | 作用 | 实测建议 |
|---|---|---|
eggs | 要伪造的distribution name列表 | 必填,多个包名换行分隔 |
fake-eggs-dir | 指定fake egg的输出目录,默认是当前part目录 | 适合把fake产物集中到一个目录,方便清理 |
versions | 读取[versions]段指定版本号 | 推荐显式在[versions]锁定,别依赖默认 |
extra-paths | 额外追加的Python路径 | 需要把非egg路径加入搜索范围时使用,较少见 |
fake-eggs-dir这个参数我印象比较深。有次CI环境里多个job共享同一个workspace,buildout每一次都会重新生成fake eggs,如果不指定统一目录,很可能因为part目录残留导致后续job把旧fake egg也加载进sys.path。后来我在所有构建配置里都把fake eggs的输出目录固定为${buildout:directory}/fake-eggs,情况就好了很多。
3.4 和其他recipe配合时的路径继承
fakezope2eggs单独使用时只能生成fake eggs,它不会自动帮你创建测试入口、启动脚本之类的可执行文件。实际项目中,它几乎总是和zc.recipe.testrunner或zc.recipe.egg配合使用。
最常见的配合写法是这样的:
[test] recipe = zc.recipe.testrunner eggs = my.package ${fakezope2:eggs}这里${fakezope2:eggs}是一个buildout的变量引用,表示把[fakezope2]部分里定义的eggs列表原样继承过来。好处是依赖清单只维护一处,不用在多个recipe里重复写。zc.recipe.testrunner在生成bin/test脚本时,会把这些包名对应的distribution路径都加入sys.path,其中就包含fake eggs里伪造的那几个包。
需要注意的是,路径继承是把双刃剑。如果[fakezope2]里的eggs列表特别大,那么bin/test脚本启动时,pkg_resources会加载所有这些distribution记录。本身加载一个fake egg的元数据非常快,但如果同一环境里混有真实egg和fake egg,且版本有交叉,冲突的概率会直线上升。这个坑我放到后面排查章节详细说。
4. 实际案例:给老项目搭一个只跑单测的Plone插件环境
4.1 项目背景与目标
为了把前面的语法和参数落到一个能直接“抄作业”的场景里,我整理一个自己经历过的真实案例。假设现在要接手一个遗留的Plone 4.3自定义产品,包名叫my.policy,它的setup.py里声明依赖了Products.CMFPlone。代码主要包含一些Configure.zcml、浏览器viewlet和schema扩展,不涉及Zope 2服务端内部运行。项目目标是:在尽可能短的时间内,搭出一个能跑zope.testrunner单元测试的隔离环境,不安装完整Zope 2、不启动Plone实例。
在这个需求下,affinitic.recipe.fakezope2eggs的价值体现得淋漓尽致。
4.2 完整buildout配置
buildout.cfg长这样:
[buildout] parts = fakezope2 test develop = src/my.policy versions = versions find-links = http://dist.plone.org/release/4.3.20 [fakezope2] recipe = affinitic.recipe.fakezope2eggs fake-eggs-dir = ${buildout:directory}/fake-eggs eggs = Zope2 Products.CMFCore Products.CMFPlone [test] recipe = zc.recipe.testrunner eggs = my.policy ${fakezope2:eggs} [versions] Zope2 = 2.13.26 Products.CMFPlone = 4.3.20重点说三个细节:
第一,[buildout]里加了develop = src/my.policy,这是为了让buildout把开发中的本地包作为development egg纳入依赖解析。否则my.policy根本不会被识别。
第二,fake-eggs-dir指定到了项目根目录下的fake-eggs,而不是默认的parts/fakezope2。后面手动清理时非常方便,一个rm -rf fake-eggs就能全部清掉,不会误伤parts里其他内容。
第三,[versions]锁定了Zope2和Products.CMFPlone的版本。这里版本号来自老项目原本运行环境中的实际版本。锁定版本的意义,我在第3.2节说过,这里不重复。
4.3 执行过程与预期输出
运行bin/buildout -v(加-v是为了在出错时能看到详细日志,首次执行建议加上):
bin/buildout -v正常输出里会依次出现:
Getting distribution for 'affinitic.recipe.fakezope2eggs'. ... Installing fakezope2. ... Installing test.第一次执行时,buildout会先尝试下载affinitic.recipe.fakezope2eggs这个recipe包本身。因为配置里写了find-links = http://dist.plone.org/release/4.3.20,所以它会从Plone官方发布目录中找到对应包并安装。这一步需要网络可用;如果在内网离线环境,可以提前把recipe包下载到本地,再把find-links指向本地目录。接着执行fakezope2part,生成fake eggs到fake-eggs/目录。最后执行testpart,根据zc.recipe.testrunner的逻辑生成bin/test脚本。
我在第一次跑这个配置时特别留意了一个现象:虽然Products.CMFPlone的install_requires里明确依赖了Zope2,但buildout并没有去dist.plone.org下载真实的Zope2。原因就是fakezope2eggs在前面已经把版本匹配的fakeZope2放进了working_set。这个操作的成功与否,可以从-v日志里看到一条类似Adding fake Zope2 2.13.26的记录,证明fake distribution被认可了。
4.4 效果验证和运行测试的注意事项
环境搭建完成后,跑一下单元测试试试:
bin/test -s my.policy如果测试代码里没有真正导入Zope2运行模块,测试会正常进入收集阶段并执行。我那个老项目一共八十多个测试用例,跑下来只花了几秒钟,而之前完整安装Zope 2后冷启动测试需要半分钟以上,速度提升非常明显。
但我要强调一个边界:fake egg只解决依赖解析,不解决运行期导入。如果测试代码里出现类似:
from Zope2 import App这种真实导入行为的语句,在fake环境下会直接ImportError。因为fakeZope2里没有App模块。遇到这种情况,你只有两个选择:要么想办法用mock替代真实模块,要么放弃fake egg,老老实实安装真实Zope 2。在我的经验里,fake egg适合的场景是“测试代码本身不依赖Zope 2内部模块”的项目,比如大部分纯粹的逻辑层schema扩展、utility注册、viewlet代码。如果项目测试已经深度耦合了Zope 2的请求/响应对象,fake egg救不了你。
5. 源码角度:recipe到底是怎么“变”出一个egg的
5.1 install()方法是核心
理解了用法,再看一眼实现原理,遇到奇怪问题时会更容易定位。fakezope2eggs作为一个recipe,首先是遵循zc.buildout的recipe协议实现的。一个recipe类至少要提供install()和update()两个方法,buildout在安装和更新part时分别调用。
install()方法的核心逻辑,是遍历用户在eggs参数里给出的每个包名,然后调用一个私有函数生成fake egg。这个函数的职责可以用一个简化的伪代码演示:
def _create_fake_egg(project_name, version, dest): egg_name = f"{project_name}-{version}-py2.7.egg" egg_dir = os.path.join(dest, egg_name) os.makedirs(os.path.join(egg_dir, project_name)) os.makedirs(os.path.join(egg_dir, "EGG-INFO")) with open(os.path.join(egg_dir, "EGG-INFO", "PKG-INFO"), "w") as f: f.write(f"Metadata-Version: 1.0\n" f"Name: {project_name}\n" f"Version: {version}\n") with open(os.path.join(egg_dir, "EGG-INFO", "top_level.txt"), "w") as f: f.write(project_name + "\n") with open(os.path.join(egg_dir, project_name, "__init__.py"), "w") as f: f.write("")这段代码不是逐行照抄真实源码,但行为逻辑是一致的:生成最小的包目录和元数据文件。真实版本里还会根据包的namespace属性决定是否在__init__.py里写入__path__扩展代码,并处理requires.txt的生成。如果你对确切实现感兴趣,直接去site-packages里看该包源码就行。
5.2 为什么能骗过pkg_resources的检查
pkg_resources在解析依赖时,核心是遍历sys.path中所有名为项目名-版本-py版本.egg的目录,依次读取目录内的EGG-INFO/PKG-INFO,构造Distribution对象。然后,当Requirement对象去working set里找匹配时,会比对project_name和版本范围。
fake egg的目录命名和PKG-INFO字段都是标准格式,所以在sys.path扫描阶段就能被识别为合法distribution。后面做依赖匹配时,只要版本号落在范围内,就直接通过。这就是所谓的“骗过检查”的真相——不是魔法,而是伪造了正常egg的关键元数据。
5.3 这种做法的边界与隐患
既然fake egg是伪造出来的,那它必然在真实环境里埋了一些坑。最典型的问题有两个:
第一,如果构建环境里同时存在fake egg和一个后来真实安装的同名不同版本egg,pkg_resources的working set会出现“双distribution”状态。旧版本可能因为路径顺序问题被激活,导致真实安装的版本“隐身”。这种问题极其隐蔽,排错时非常浪费时间。
第二,namespace package的处理。老的Products.*系列包都是挂在Products这个namespace下的。一个正常的Products包,__init__.py里会通过__import__('pkg_resources').declare_namespace(__name__)来声明namespace。fake egg如果直接生成一个空__init__.py,会导致Products不被识别为namespace package,之后任何import Products.CMFPlone都会失败,因为Python解释器认为Products包已经存在,但没有子模块。
我在实际项目中遇到过这个坑,后面排查时专门把fake egg里的Products/__init__.py改成标准namespace声明才解决。fakezope2eggs的高版本通常会自动处理这个逻辑,但如果你使用的是某个老版本,就要特别小心。
6. 我在实际项目里踩过的坑,以及排查思路
6.1 坑:版本号对不上,导致后来真实安装时冲突
这个坑出现得最频繁。有一次我在[versions]里把Zope2写成了2.13.25,但项目的某个依赖要求Zope2>=2.13.26。buildout解析时,fake egg的版本不满足约束,于是它无视fake,开始疯狂找真实Zope2 2.13.26的下载源。内网环境自然找不到,最后报错。
排查思路分三步:
第一步,看报错。buildout报错信息在这里非常直白,通常会说We have no distributions for Zope2 that satisfies 'Zope2>=2.13.26'。这提示版本约束没被满足。
第二步,检查[versions]里的版本号和报错信息里的required range是否匹配。最好拿bin/buildout -v重新跑一遍,在日志里确认fake egg创建时用的具体版本号。
第三步,修改[versions],让fake egg的版本号覆盖所有依赖要求的约束范围。比如统一写成2.13.26,通常就能通过。
6.2 坑:Products.*这种namespace package的导入问题
前面源码部分提过的namespace坑,发生后表现是:buildout安装一切正常,pkg_resources.get_distribution('Products.CMFCore')也能返回结果,但一执行测试就报:
ImportError: No module named Products.CMFCore这个报错非常具有迷惑性,因为明明fake egg里创建了Products/CMFCore/__init__.py。真正原因是Products包没有被声明为namespace package,导致Products模块的__path__里不包含fake eggs目录,Python解释器在查找Products.CMFCore子模块时只能找到系统中其他位置的Products目录,找不到fake eggs里那个。
解决办法有两个方向:一是彻底删除系统里其他Products目录,让解释器唯一能看到的Products就是fake eggs里的那个;二是在fake egg的Products/__init__.py里写入正确的namespace声明代码,例如:
__import__('pkg_resources').declare_namespace(__name__)修改后重新跑buildout,让fake egg重新生成。在验证时可以用:
import Products print(Products.__path__)查看Products.__path__是否包含fake eggs目录下的路径。包含,问题就解决了。
6.3 坑:buildout缓存了旧的fake eggs,更新后不生效
我曾遇到过修改[versions]里版本号后,重新跑buildout,旧版本的fake egg依然留在fake-eggs目录里的情况。因为buildout的update()方法在某些情况下并不会删除旧的生成文件,只是把新版本fake egg追加到目录里。于是目录里同时躺着Zope2-2.13.25-py2.7.egg和Zope2-2.13.26-py2.7.egg。pkg_resources对working set的构造受sys.path顺序影响,可能导致旧版本、新版本随机被激活,行为变得不可预测。
遇到这种问题别去猜,直接手动清理后重建:
rm -rf fake-eggs bin/buildout install fakezope2bin/buildout install fakezope2会强制重新安装fakezope2这个part,重新生成所有fake eggs。之后的测试结果就稳定了。
这个坑让我养成一个习惯:任何对fake包版本的修改,都不要只依赖buildout的增量更新,直接删目录重建更省心。
6.4 一些有用的排查命令
最后整理几条我在排错过程中觉得最实用的命令,按使用频率排序:
# 查看当前python环境里所有已注册的Zope相关distribution bin/python -c "import pkg_resources; [print(d) for d in pkg_resources.working_set if 'Zope' in d.project_name]" # 查看fake egg目录的结构 find fake-eggs -maxdepth 2 -type f | head # 查看Products包的搜索路径 bin/python -c "import Products; print(Products.__path__)" # 强制重建fakezope2 part和test part bin/buildout install fakezope2 test我的体会是,fakezope2eggs这个包用起来不复杂,真正复杂的场景几乎都集中在“fake egg与真实环境共存”时产生的边界问题。只要理解它只在依赖解析阶段生效,运行期一切如实,就能把它用在该用的地方,避开大多数坑。如果你也尝试用它对老项目做测试环境瘦身,遇到上面类似的问题,不妨按这几条思路排查一遍。