插件案例研究:Pluggy
摘要
一篇博客文章,探讨了Pluggy——这是一个最初源自pytest的Python库,用于构建插件系统。内容包括其工作原理以及如何将其与一个玩具级HTML转换工具配合使用。
<p>最近我遇到了<a class="reference external" href="https://pluggy.readthedocs.io/en/latest/">Pluggy</a>,这是一个用于开发插件系统的Python库。它最初是作为<tt class="docutils literal">pytest</tt>项目的一部分开发的——该项目以其丰富的插件生态系统而闻名——后来被提取为一个独立的库。如果你想为你的工具或库添加插件系统,并且希望使用经过验证的方案而不是自己从头实现,那么就应该使用Pluggy。</p>
<p>在这篇文章中,我将分享一些关于Pluggy如何工作的笔记,然后审视它如何与<a class="reference external" href="https://eli.thegreenplace.net/2012/08/07/fundamental-concepts-of-plugin-infrastructures">插件基础设施的基本概念</a>保持一致。</p>
<img alt="Pluggy插头标志" class="align-center" src="https://eli.thegreenplace.net/images/2026/pluggy-plug.png" />
<div class="section" id="using-pluggy">
<h2>使用Pluggy</h2>
<p>Pluggy围绕<em>钩子</em>的概念构建:宿主应用程序或工具(以下简称为“宿主”)暴露的、由插件实现的函数。宿主通过使用从<tt class="docutils literal">pluggy.HookspecMarker</tt>返回的装饰器来暴露钩子,而插件则使用从<tt class="docutils literal">pluggy.HookimplMarker</tt>返回的装饰器来实现此钩子。</p>
<p>Pluggy的<a class="reference external" href="https://pluggy.readthedocs.io/en/stable/">文档</a>对此有很好的解释;在这篇文章中,我将展示如何用一些插件实现<tt class="docutils literal">htmlize</tt>工具,该工具在<a class="reference external" href="https://eli.thegreenplace.net/2012/08/07/fundamental-concepts-of-plugin-infrastructures">我插件系列文章中的原始文章</a>中介绍过。</p>
<p>提醒一下,<tt class="docutils literal">htmlize</tt>是一个玩具工具,它接受类似于reStructuredText的标记符号,并将其转换为HTML。它支持插件来处理自定义的“角色”,例如:</p>
<div class="highlight"><pre><span></span>some text :role:`customized text` and more text
</pre></div>
<p>以及对整个文本进行任意处理的插件。</p>
<div class="section" id="defining-hooks">
<h3>定义钩子</h3>
<p><a class="reference external" href="https://github.com/eliben/code-for-blog/tree/main/2026/plugin-pluggy/htmlize/htmlize">我们的宿主</a>定义了两个钩子:</p>
<div class="highlight"><pre><span></span><span class="kn">import</span> <span class="nn">pluggy</span>
<span class="n">hookspec</span> <span class="o">=</span> <span class="n">pluggy</span><span class="o">.</span><span class="n">HookspecMarker</span><span class="p">(</span><span class="s2">"htmlize"</span><span class="p">)</span>
<span class="nd">@hookspec</span><span class="p">(</span><span class="n">firstresult</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">htmlize_role_handler</span><span class="p">(</span><span class="n">role_name</span><span class="p">):</span>
<span class="sd">"""Return a function accepting role contents.</span>
<span class="sd"> The function will be called with a single argument - the role contents, and</span>
<span class="sd"> should return what the role gets replaced with.</span>
<span class="sd"> """</span>
<span class="k">pass</span>
<span class="nd">@hookspec</span>
<span class="k">def</span> <span class="nf">htmlize_contents</span><span class="p">(</span><span class="n">post</span><span class="p">,</span> <span class="n">db</span><span class="p">):</span>
<span class="sd">"""Return a function accepting full document contents.</span>
<span class="sd"> The function will be called with a single argument - the document contents</span>
<span class="sd"> (after paragraph splitting and role processing), and should return the</span>
<span class="sd"> transformed contents.</span>
<span class="sd"> """</span>
<span class="k">pass</span>
</pre></div>
<p>钩子通过调用<tt class="docutils literal">HookspecMarker</tt>并传入项目名称来创建。此项目名称必须在宿主及其插件之间匹配。Pluggy对钩子接受的参数及其返回值比较宽松;为了最大灵活性和忠实于原始的<tt class="docutils literal">htmlize</tt>示例,我们的钩子返回函数。</p>
<p>为了配合这个<tt class="docutils literal">HookspecMarker</tt>,宿主还定义了一个同名的<tt class="docutils literal">HookimplMarker</tt>:</p>
<div class="highlight"><pre><span></span><span class="n">hookimpl</span> <span class="o">=</span> <span class="n">pluggy</span><span class="o">.</span><span class="n">HookimplMarker</span><span class="p">(</span><span class="s2">"htmlize"</span><span class="p">)</span>
</pre></div>
<p>插件在加载时使用它来附加到钩子上。</p>
</div>
<div class="section" id="loading-plugins-in-the-host">
<h3>在宿主中加载插件</h3>
<p>宿主的main函数在启动时按如下方式加载插件:</p>
<div class="highlight"><pre><span></span><span class="n">pm</span> <span class="o">=</span> <span class="n">pluggy</span><span class="o">.</span><span class="n">PluginManager</span><span class="p">(</span><span class="s2">"htmlize"</span><span class="p">)</span>
<span class="n">pm</span><span class="o">.</span><span class="n">add_hookspecs</span><span class="p">(</span><span class="n">hookspecs</span><span class="p">)</span>
<span class="n">pm</span><span class="o">.</span><span class="n">load_setuptools_entrypoints</span><span class="p">(</span><span class="s2">"htmlize"</span><span class="p">)</span>
</pre></div>
<p><tt class="docutils literal">hookspecs</tt>是我们的Python模块,包含上面展示的钩子。<tt class="docutils literal">load_setuptools_entrypoints</tt>是Pluggy的辅助函数,用于加载通过<tt class="docutils literal">pip</tt>安装到同一环境并注册为setuptools <a class="reference external" href="https://setuptools.pypa.io/en/latest/userguide/entry_point.html">入口点</a>的插件。这是一种在<tt class="docutils literal">setup.py</tt>或<tt class="docutils literal">pyproject.toml</tt>文件中标记一些元数据的方式,项目可以在运行时检查这些元数据。在我们的项目中,插件通过<tt class="docutils literal">pyproject.toml</tt>文件中的以下部分注册自身:</p>
<div class="highlight"><pre><span></span>[project.entry-points.htmlize]
tt = "tt"
</pre></div>
<p>这表示“对于入口点<tt class="docutils literal">htmlize</tt>,定义一个名为<tt class="docutils literal">tt</tt>的新入口”。然后Pluggy的<tt class="docutils literal">load_setuptools_entrypoints</tt>使用<a class="reference external" href="https://docs.python.org/3/library/importlib.metadata.html">importlib.metadata</a>来访问此信息。</p>
<p>注意,Pluggy不要求使用此机制。宿主可以实现任何想要的插件发现方法,并通过<tt class="docutils literal">register</tt>方法直接将插件添加到它们的<tt class="docutils literal">PluginManager</tt>中。但这是用于<tt class="docutils literal">pytest</tt>和许多其他项目的机制;它使得自动发现和注册通过<tt class="docutils literal">pip</tt>及类似工具安装的插件变得非常容易。</p>
</div>
<div class="section" id="invoking-plugins">
<h3>调用插件</h3>
<p>一旦<tt class="docutils literal">PluginManager</tt>加载了插件,调用它们就很简单了;以下是<tt class="docutils literal">htmlize</tt>调用内容钩子的方式<a class="footnote-reference" href="#footnote-1" id="footnote-reference-1">[1]</a>:</p>
<div class="highlight"><pre><span
查看缓存全文
缓存时间: 2026/06/14 07:39
# 插件案例分析:Pluggy - Eli Bendersky 的网站
来源:https://eli.thegreenplace.net/2026/plugins-case-study-pluggy
最近我发现了 [Pluggy](https://pluggy.readthedocs.io/en/latest/),一个用于开发插件系统的 Python 库。它最初是作为 [pytest](https://pytest.org) 项目(以其丰富的插件生态而闻名)的一部分开发的,后来被独立提取为一个库。如果你想为自己的工具或库添加插件系统,并且希望使用经过验证的方案而不是自己从头编写,那么你应该考虑使用 Pluggy。
在本文中,我将分享一些关于 Pluggy 如何工作的笔记,然后回顾它如何与 [插件基础设施的基本概念](https://eli.thegreenplace.net/2012/08/07/fundamental-concepts-of-plugin-infrastructures) 相契合。
Pluggy 插件标识
## 使用 Pluggy
Pluggy 建立在 **钩子(hooks)** 的概念之上:宿主应用程序或工具(以下简称为“宿主”)公开的函数,由插件实现。宿主通过使用 `pluggy.HookspecMarker` 返回的装饰器来公开钩子,插件则使用 `pluggy.HookimplMarker` 返回的装饰器来实现该钩子。
Pluggy 的[文档](https://pluggy.readthedocs.io/en/stable/)对此解释得相当清楚;在本文中,我将展示如何用插件实现 `htmlize` 工具,该工具在[我的插件系列文章](https://eli.thegreenplace.net/2012/08/07/fundamental-concepts-of-plugin-infrastructures)中首次介绍。提醒一下,`htmlize` 是一个玩具工具,它接受类似于 reStructuredText 的标记符号,并将其转换为 HTML。它支持插件处理自定义“角色”,例如:
`` some text :role:`customized text` and more text ``
也支持对整个文本进行任意处理的插件。
### 定义钩子
我们的宿主([代码仓库](https://github.com/eliben/code-for-blog/tree/main/2026/plugin-pluggy/htmlize/htmlize))定义了两个钩子:
```python
import pluggy
hookspec = pluggy.HookspecMarker("htmlize")
@hookspec(firstresult=True)
def htmlize_role_handler(role_name):
"""返回一个接受角色内容的函数。
该函数将接收一个参数——角色内容,并应返回该角色被替换后的内容。
"""
pass
@hookspec
def htmlize_contents(post, db):
"""返回一个接受整个文档内容的函数。
该函数将接收一个参数——文档内容(在段落拆分和角色处理之后),并应返回转换后的内容。
"""
pass
```
通过使用项目名称调用 `HookspecMarker` 来创建钩子。这个项目名称在宿主和插件之间必须匹配。Pluggy 对钩子接受的参数和返回的内容非常宽容;为了最大限度地保持灵活性并忠实于原始 `htmlize` 示例,我们的钩子返回函数。
为了配合这个 `HookspecMarker`,宿主还定义了一个同名的 `HookimplMarker`:
```python
hookimpl = pluggy.HookimplMarker("htmlize")
```
插件在加载时使用这个标记来关联钩子。
### 在宿主中加载插件
宿主的 main 函数在启动时按如下方式加载插件:
```python
pm = pluggy.PluginManager("htmlize")
pm.add_hookspecs(hookspecs)
pm.load_setuptools_entrypoints("htmlize")
```
`hookspecs` 是我们包含上述钩子的 Python 模块。`load_setuptools_entrypoints` 是 Pluggy 的辅助方法,用于加载通过 `pip` 安装到同一环境并注册为 [setuptools 入口点](https://setuptools.pypa.io/en/latest/userguide/entry_point.html) 的插件。这是一种在 `setup.py` 或 `pyproject.toml` 文件中标记某些元数据的方式,项目可以在运行时审查这些元数据。在我们的项目中,插件通过 `pyproject.toml` 文件中的以下部分进行注册:
```toml
[project.entry-points.htmlize]
tt = "tt"
```
这意味着“对于入口点 `htmlize`,定义一个名为 `tt` 的新条目”。然后 Pluggy 的 `load_setuptools_entrypoints` 使用 `importlib.metadata` 来访问这些信息。注意,Pluggy 并不要求使用这种机制。宿主可以实现任何想要的插件发现方法,并通过 `register` 方法直接将插件添加到其 `PluginManager` 中。但这是 `pytest` 和许多其他项目使用的机制;它使得自动发现和注册通过 `pip` 及类似工具安装的插件变得非常简单。
### 调用钩子
一旦 `PluginManager` 加载了插件,调用它们就很简单了;下面是 `htmlize` 如何调用内容钩子[1]:
```python
# 重新组合完整内容,并让插件对内容进行操作。
contents = ''.join(parts)
for handler in plugin_manager.hook.htmlize_contents(post=post, db=db):
contents = handler(contents)
return contents
```
通常,钩子调用会返回一个 **列表**,包含所有不同插件附加的钩子结果(单个宿主应用程序可以安装多个插件,并关联到同一个钩子)。当宿主像上面那样调用钩子时,默认顺序是 LIFO(后进先出),但插件可以通过 [钩子选项](https://pluggy.readthedocs.io/en/stable/#call-time-order)(如 `tryfirst` 和 `trylast`)来影响顺序。
### 在插件中实现钩子
以下是我们完整的 `narcissist` 插件,它关联到内容钩子:
```python
import htmlize
@htmlize.hookimpl
def htmlize_contents(post, db):
repl = f'I ({post.author})'
def hook(contents):
return re.sub(r'\bI\b', repl, contents)
return hook
```
几点说明:
- 它要求已安装 `htmlize`;如前所述,我们依赖 Pluggy 默认的基于安装的方法,即宿主和插件都安装到相同的 Python 环境中,因此可以互相找到。不过,Pluggy 支持任何自定义的发现方法。
- 它使用了前面显示的 `hookimpl` 导入值。
- 它返回一个作用于内容的函数;这是我们之前讨论过的 `htmlize` 特定约定(如果你愿意,可以称为 ABI)。
## 基本插件概念在案例分析中的体现
让我们看看这个 Pluggy 案例与之前多次在本博客中讨论的 [基本插件概念](https://eli.thegreenplace.net/tag/plugins) 是如何对应的。需要记住的是,Pluggy 不是一个具有特定插件系统的宿主应用程序;相反,它是一个用于创建此类插件系统的可复用库。因此,这更像是一个 **元(meta)** 案例分析。
### 发现
通常,Pluggy 将发现逻辑留给用户自行决定。它的 `PluginManager` 有一个 `register` 方法用于添加插件,应用程序可以选择任何方式发现它们。不过,Pluggy 自带一种内置的发现机制——通过 Python 打包的入口点流程,如上所示。这对于大量应用程序来说非常方便,只要应用程序及其插件都通过标准的 Python 打包工具安装(这在 Python 生态系统中是一个相当合理的假设)。
### 注册
在入口点流程中,插件通过在其 `pyproject.toml` 文件中添加 `[project.entry-points.]` 部分来自行注册。否则,如上所述,用户可以自由设计自己的注册方案。
### 钩子
这一点很简单,因为 Pluggy 的术语中也叫作 **钩子(hooks)**!Pluggy 的钩子实现相当优雅,提供了函数装饰器供插件设置。我们在上面已经看到了一个例子,`@htmlize.hookimpl` 装饰了 `htmlize_contents`。
### 向插件公开应用程序 API
由于 Pluggy 是为 Python 宿主和 Python 插件设计的,这一点相当直接。插件通常假设宿主项目已经安装在 Python 环境中,并且其模块可以被导入。在我们的例子中,插件从 `htmlize` 导入 `hookimpl` 来实现这一点。它还展示了宿主如何将数据传递给插件——`post` 和 `db` 参数。这些都是宿主公开的、供插件使用的 API。
## 结论——Pluggy 值得吗?
在我最初关于 [插件基础设施基本概念](https://eli.thegreenplace.net/2012/08/07/fundamental-concepts-of-plugin-infrastructures) 的文章的脚注 2 中,我写道[2]:
> 这可能就是为什么现有成熟的插件框架很少的原因(即使在像 C 或 C++ 这样的底层语言中也是如此)。自己动手太容易(也太诱人)了。
我仍然认为我的说法是正确的——插件框架非常容易创建,而且它们提供的功能相对于其大面积的 API 来说相对较小。换句话说,这是一个 **浅层 API**。
不过,对于更高级的插件使用情况,Pluggy 确实提供了一些不错的功能:
- 自动入口点注册机制——如果你需要的话
- 签名验证
- 跨单个插件的多个钩子关联以及跨多个插件的统一插件结果收集
- 通过 `firstresult`、`tryfirst`、`trylast` 等实现插件排序
- 用于某些特殊用例的钩子“包装器”
这些对你的项目有用吗?这完全取决于具体项目,同时始终要牢记 [依赖项和项目工作量之间的权衡](https://eli.thegreenplace.net/2017/benefits-of-dependencies-in-software-projects-as-a-function-of-effort/)。
## 代码
本文的完整代码仓库可在此处获取:https://github.com/eliben/code-for-blog/tree/main/2026/plugin-pluggy
---
[1] 这里 `plugin_manager` 是之前从 `pluggy.PluginManager` 返回的值;在上一个代码片段中它被保存为 `pm`——变量名不同是因为进行了函数调用,且 `plugin_manager` 是参数名。
[2] 公平地说,那篇文章的发布时间早于 Pluggy 的创建!
---
如需评论,请发送电子邮件至 [email protected]。
相似文章
pytest-dev/pytest
pytest 是一个成熟的 Python 测试框架,可以轻松编写简单的测试,同时能够扩展以支持应用程序和库的复杂功能测试。
PlugThis
PlugThis 是一个工具,让你轻松生成 Chrome 扩展,类似于 Lovable,但专注于浏览器扩展。
用于构建Claude Code hooks的Python工具包
一个减少构建Claude Code hooks样板代码的Python工具包,提供类型安全的事件处理器,用于工具使用前/后、提示提交和会话开始钩子。
cursor/plugins
Cursor 发布了一套面向流行开发者工具的官方插件,包括持续学习、团队工作流、代码审查等功能,现已在 GitHub 上作为市场仓库提供。
我构建了一个开源Claude Code插件,强制实施规范驱动的工作流(访谈→规范→计划→任务→代码)
介绍Specsmith,一个用于Claude Code的开源插件,强制实施规范驱动的工作流(访谈、规范、计划、任务、代码),以减少歧义并提高代码质量,目前处于早期v0.1版本。