bigywater.tech
Python · 95

Python entry points:命令行和插件是如何被发现的

装完一个包,终端里就多了一个命令;装完另一个包,某个程序就多了一个插件——它们靠的是同一个机制:entry points。这篇在一个全新的虚拟环境里真的装一遍,一路看到:命令行脚本长什么样、插件是怎么被「发现」而不导入的、发现的成本有多大。

6 行
pip 生成的命令行启动脚本 hello-cli 的全部内容(含 shebang):import → 调用 → 退出
不导入代码
entry_points() 只读 dist-info 里的文本,load() 才导入
4 → 70 ms
已安装的包从 0 增加到 1600 个时,一次 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

验证一:命令行脚本只是 pip 生成的几行

实测 · 本机执行输出
  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') = ABC

importlib.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 是我批量生成的最小文件,真实包的元数据更大,数字仅作量级参考。)

分步演示:从声明到被发现

每一步的内容都是上面实验的真实输出
entry points 的一生
当前阶段 / 数据

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7,pip 26.2.1,标准库 importlib.metadata;打包用的是自己写的最小 build backend
    • 没有验证:gui_scripts、Windows 上生成的 .exe 启动器、setuptools / hatchling 对 entry points 的额外处理、多个包声明同名 entry point 时的冲突
    京ICP备2026067502号-1京公网安备11010502063487号