装完一个包,终端里就多了一个命令;装完另一个包,某个程序就多了一个插件——它们靠的是同一个机制:entry points。这篇在一个全新的虚拟环境里真的装一遍,一路看到:命令行脚本长什么样、插件是怎么被「发现」而不导入的、发现的成本有多大。
pyproject.toml(节选) [project.scripts] hello-cli = "hello_app.cli:main" [project.entry-points."hello_app.plugins"] upper = "hello_app.plugins:upper" reverse = "hello_app.plugins:reverse"
build backend 是第 94 篇里那个手写的最小后端,多加了一步:把这两段写进 wheel 里的 entry_points.txt。安装用的是 pip,装进一个全新的虚拟环境。
pip install ./proj → 返回码 0 | Successfully installed hello-app-0.2.0
site-packages 里的 dist-info: ['pip-26.2.1.dist-info', 'hello_app-0.2.0.dist-info']
hello_app 的 entry_points.txt 内容:
[console_scripts]
hello-cli = hello_app.cli:main
[hello_app.plugins]
upper = hello_app.plugins:upper
reverse = hello_app.plugins:reverse env/bin/ 里多了: ['hello-cli']
env/bin/hello-cli 的内容:
#!<工作目录>/env/bin/python
import sys
from hello_app.cli import main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(main())
运行 hello-cli a b → [hello_app 包被导入了]
hello-cli 运行, argv = ['a', 'b'][console_scripts] 里的 hello-cli = hello_app.cli:main,被 pip 变成 env/bin/hello-cli:一个以虚拟环境 Python 为解释器的脚本,from hello_app.cli import main 然后 sys.exit(main())。所以命令行工具其实就是「一个 import + 一次函数调用」。卸载时这个脚本会被 pip 一并删除(见验证五)。
找到 2 个: ['reverse', 'upper']
此时 hello_app 被导入了吗? False (只读了元数据文件, 没导入代码)
EntryPoint: name='reverse' value='hello_app.plugins:reverse' group='hello_app.plugins' dist=hello-app==0.2.0
EntryPoint: name='upper' value='hello_app.plugins:upper' group='hello_app.plugins' dist=hello-app==0.2.0
[hello_app 包被导入了]
[plugins 模块被导入了]
load() 之后 hello_app 被导入了吗? True
upper('abc') = ABCimportlib.metadata.entry_points(group=...) 返回的每个 EntryPoint 只包含名字、字符串形式的 value、所属分发包——这个时候 hello_app 完全没有被导入("hello_app" in sys.modules 是 False)。只有调用 .load() 才会导入模块并返回那个对象。这就是插件系统可以「先列出所有可用插件,用户选了哪个再加载哪个」的原因,也避免了因为某个插件写得差而拖慢整个程序启动。
现在找到: ['ghost', 'reverse', 'upper'] ghost 的 load() → ModuleNotFoundError: No module named 'nowhere'
我没有安装任何包,只是往 site-packages 里手工放了一个只有 METADATA 和 entry_points.txt 的目录,插件 ghost 就出现在列表里;直到 load() 才发现它指向的 nowhere 模块并不存在。所以发现阶段拿到的名字不代表能用——加载时要处理 ImportError。
pip uninstall → 0 env/bin/ 里还有 hello-cli? False | 再查 entry_points: []
卸载后启动脚本被删,再查 entry_points 得到空列表——机制的另一面:一切都由 dist-info 文件的存在与否决定。
额外 0 个已安装的包(每个一个 entry point): entry_points(group=...) 用时 3.51 ms (5 次取中位数) 额外 100 个已安装的包(每个一个 entry point): entry_points(group=...) 用时 7.59 ms (5 次取中位数) 额外 400 个已安装的包(每个一个 entry point): entry_points(group=...) 用时 19.65 ms (5 次取中位数) 额外 1600 个已安装的包(每个一个 entry point): entry_points(group=...) 用时 70.18 ms (5 次取中位数)
额外的每个已安装包只带一个 entry point 时,一次 entry_points(group=...) 要遍历所有 dist-info:空环境 3.5 ms,1600 个包 70 ms。所以不要在热路径里反复调用,启动时查一次并缓存。(这个测试的 dist-info 是我批量生成的最小文件,真实包的元数据更大,数字仅作量级参考。)
importlib.metadata;打包用的是自己写的最小 build backendgui_scripts、Windows 上生成的 .exe 启动器、setuptools / hatchling 对 entry points 的额外处理、多个包声明同名 entry point 时的冲突