插件案例研究:Pluggy

Eli Bendersky 工具

摘要

一篇博客文章,探讨了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">&quot;htmlize&quot;</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">&quot;&quot;&quot;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"> &quot;&quot;&quot;</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">&quot;&quot;&quot;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"> &quot;&quot;&quot;</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">&quot;htmlize&quot;</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">&quot;htmlize&quot;</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">&quot;htmlize&quot;</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 = &quot;tt&quot; </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

GitHub Trending (daily)

pytest 是一个成熟的 Python 测试框架,可以轻松编写简单的测试,同时能够扩展以支持应用程序和库的复杂功能测试。

PlugThis

Product Hunt

PlugThis 是一个工具,让你轻松生成 Chrome 扩展,类似于 Lovable,但专注于浏览器扩展。

用于构建Claude Code hooks的Python工具包

Hacker News Top

一个减少构建Claude Code hooks样板代码的Python工具包,提供类型安全的事件处理器,用于工具使用前/后、提示提交和会话开始钩子。

cursor/plugins

GitHub Trending (daily)

Cursor 发布了一套面向流行开发者工具的官方插件,包括持续学习、团队工作流、代码审查等功能,现已在 GitHub 上作为市场仓库提供。