插件案例研究:PluggyPlugins case study: Pluggy
Pluggy 是一个专为开发插件系统设计的 Python 库,最初作为 pytest 项目的一部分诞生,后被提取为独立的通用库。它为希望在应用中引入复杂插件生态的开发者提供了一套成熟的钩子机制设计方案。通过分析其架构与实际应用场景,可以深入了解如何在大型项目中实现高扩展性和松耦合的模块化结构。
最近我偶然发现了 Pluggy,这是一个用于开发插件系统的 Python 库。它最初是作为 pytest 项目的一部分开发的——pytest 以其丰富的插件生态系统而闻名——后来被提取为一个独立的库。如果你想为自己的工具或库添加插件系统,并且希望使用经过验证的方案而不是自己从头编写,那么你应该选择 Pluggy。
在这篇文章中,我将分享一些关于 Pluggy 工作原理的笔记,然后将回顾它是如何与插件基础设施的基本概念相契合的。
使用 Pluggy
Pluggy 是围绕钩子(hooks)的概念构建的:宿主应用程序或工具(以下简称“宿主”)暴露这些函数,而插件负责实现它们。宿主通过使用 pluggy.HookspecMarker 返回的装饰器来暴露钩子,而插件则使用 pluggy.HookimplMarker 返回的装饰器来实现该钩子。
Pluggy 的文档对此解释得相当清楚;在这篇文章中,我将展示如何使用一些插件来实现 htmlize 工具,这些插件是在我插件系列的原文章中介绍过的。
提醒一下,htmlize 是一个玩具工具,它接受类似于 reStructuredText 的标记符号,并将其转换为 HTML。它支持插件来处理自定义的“角色(roles)”,例如:
some text :role:`customized text` and more text以及对整个文本执行任意处理的插件。
定义钩子
我们的宿主定义了两个钩子:
import pluggy
hookspec = pluggy.HookspecMarker("htmlize")
@hookspec(firstresult=True)
def htmlize_role_handler(role_name):
"""Return a function accepting role contents.
The function will be called with a single argument - the role contents, and
should return what the role gets replaced with.
"""
pass
@hookspec
def htmlize_contents(post, db):
"""Return a function accepting full document contents.
The function will be called with a single argument - the document contents
(after paragraph splitting and role processing), and should return the
transformed contents.
"""
pass通过使用项目名称调用 HookspecMarker 来创建钩子。这个项目名称在宿主及其插件之间必须保持一致。Pluggy 对于钩子接受什么参数和返回什么内容是很宽容的;为了获得最大的灵活性并忠实于最初的 htmlize 示例,我们的钩子返回函数。
为了配合这个 HookspecMarker,宿主还定义了一个同名的 HookimplMarker:
hookimpl = pluggy.HookimplMarker("htmlize")插件在被加载时使用它来附加到相应的钩子上。
在宿主中加载插件
宿主的主函数在启动时按如下方式加载插件:
pm = pluggy.PluginManager("htmlize")
pm.add_hookspecs(hookspecs)
pm.load_setuptools_entrypoints("htmlize")hookspecs 是我们包含上述钩子的 Python 模块。load_setuptools_entrypoints 是 Pluggy 的辅助函数,用于加载通过 pip 安装到同一环境中并注册为 setuptools 入口点(entry points)的插件。这是一种在 setup.py 或 pyproject.toml 文件中声明一些元数据的方法,项目可以在运行时查看这些元数据。在我们的项目中,插件通过 pyproject.toml 文件中的这部分内容进行注册:
[project.entry-points.htmlize]
tt = "tt"这表示“为入口点 htmlize 定义一个名为 tt 的新条目”。然后 Pluggy 的 load_setuptools_entrypoints 会使用 importlib.metadata 来访问此信息。
请注意,Pluggy 并不强制要求使用这种机制。宿主可以实现它们想要的任何插件发现方法,并使用 register 方法直接将插件添加到其 PluginManager 中。但这是 pytest 和许多其他项目所使用的机制;它使得自动发现和注册通过 pip 及等效工具安装的插件变得非常容易。
调用插件
一旦 PluginManager 加载了插件,调用它们就非常简单了;以下是 htmlize 调用 contents 钩子 [1] 的方式:
# Build full contents back again, and ask plugins to act on
# contents.
contents = ''.join(parts)
for handler in plugin_manager.hook.htmlize_contents(post=post, db=db):
contents = handler(contents)
return contents通常,钩子调用会返回一个列表,包含不同插件所附加的所有钩子的执行结果(单个宿主应用程序可以安装多个插件并附加到同一个钩子上)。当宿主如上所示调用钩子时,默认顺序是 LIFO(后进先出),但插件可以通过诸如 tryfirst 和 trylast 之类的钩子选项来影响这一顺序。
在插件中实现钩子
这是我们附加到 contents 钩子上的整个 narcissist 插件:
import htmlize
@htmlize.hookimpl
def htmlize_contents(post, db):
repl = f'<b>I ({post.author})</b>'
def hook(contents):
return re.sub(r'\bI\b', repl, contents)
return hook一些注意事项:
本案例研究中的基本插件概念
让我们来看看 Pluggy 的这个案例研究与本博客多次探讨的“基本插件概念”相比表现如何。
重要的是要记住,Pluggy 并不是一个带有定制插件系统的特定宿主应用程序;相反,它是一个用于创建此类插件系统的可复用库。因此,这更像是一个元案例研究。
发现
通常,Pluggy 将发现逻辑交由用户自行决定。它的 PluginManager 提供了一个 register 方法用于添加插件,而应用程序可以通过任何其选择的方式来发现这些插件。
话虽如此,Pluggy 内置了一种发现机制——即通过 Python 打包的入口点(entry points)过程,如上所示。这对于大量应用程序来说非常方便,只要应用程序及其插件都是通过标准的 Python 打包工具安装的(这在 Python 生态系统中是一个非常合理的假设)。
注册
在入口点过程中,插件通过在其 pyproject.toml 文件中添加一个 [project.entry-points.<HOST-ID>] 部分来进行注册。
否则——就像在上一节中那样——用户可以自由设计自己的注册方案。
钩子
这一点很简单,因为在 Pluggy 的术语中它也叫钩子(hooks)!Pluggy 的钩子实现非常优雅,插件可以使用可用的函数装饰器来进行设置。我们在上面的示例中已经看到了这一点,即使用 @htmlize.hookimpl 装饰 htmlize_contents。
向插件暴露应用程序 API
由于 Pluggy 是为 Python 宿主和 Python 插件设计的,因此这一点相当直接。插件通常假定宿主项目已经安装在 Python 环境中,并且可以导入其模块。
在我们的示例中,插件从 htmlize 导入 hookimpl 来实现这一点。它还展示了如何将宿主数据传递给插件——即 post 和 db 参数。这些是宿主暴露给插件使用的 API。
结论 —— Pluggy 值得吗?
在我最初的那篇关于插件基础设施基本概念的博文的脚注 2 中,我写道 [2]:
这可能就是为什么现有的成熟插件框架寥寥无几(即使在 C 或 C++ 这样的低级语言中也是如此)。因为自己动手写一个实在太容易(也太诱人)了。
我仍然相信我的说法是正确的——插件框架非常容易创建,而且与它们庞大的 API 表面积相比,所提供的功能相对较少。换句话说,这是一个浅层 API。
话虽如此,对于更高级的插件用途,Pluggy 确实提供了一些不错的功能:
这些对你的项目来说值得吗?这实际上取决于具体项目,并且始终需要将依赖关系与项目工作量之间的权衡铭记在心。
代码
本文的完整代码仓库可在此处获取。
如有任何意见,请给我发邮件。
需要完整排版与评论请前往来源站点阅读。