创建和发现插件#

在创建 Python 应用程序或库时,通常需要通过插件提供自定义或额外功能。由于 Python 包可以单独分发,因此应用程序或库可能需要自动发现所有可用的插件。

有三种主要方法可以进行自动插件发现

  1. 使用命名约定.

  2. 使用命名空间包.

  3. 使用包元数据.

使用命名约定#

如果应用程序的所有插件都遵循相同的命名约定,则可以使用 pkgutil.iter_modules() 发现所有与命名约定匹配的顶级模块。例如,Flask 使用命名约定 flask_{plugin_name}。如果你想自动发现所有已安装的 Flask 插件

import importlib
import pkgutil

discovered_plugins = {
    name: importlib.import_module(name)
    for finder, name, ispkg
    in pkgutil.iter_modules()
    if name.startswith('flask_')
}

如果你同时安装了 Flask-SQLAlchemyFlask-Talisman 插件,则 discovered_plugins 将为

{
    'flask_sqlalchemy': <module: 'flask_sqlalchemy'>,
    'flask_talisman': <module: 'flask_talisman'>,
}

对插件使用命名约定还允许你查询 Python 包索引的 简单存储库 API 以获取符合命名约定的所有包。

使用命名空间包#

命名空间包 可用于提供放置插件的约定,并且还提供执行发现的方法。例如,如果你将子包 myapp.plugins 设为命名空间包,则其他 发行版 可以向该命名空间提供模块和包。安装后,你可以使用 pkgutil.iter_modules() 发现安装在该命名空间下的所有模块和包

import importlib
import pkgutil

import myapp.plugins

def iter_namespace(ns_pkg):
    # Specifying the second argument (prefix) to iter_modules makes the
    # returned name an absolute name instead of a relative one. This allows
    # import_module to work without having to do additional modification to
    # the name.
    return pkgutil.iter_modules(ns_pkg.__path__, ns_pkg.__name__ + ".")

discovered_plugins = {
    name: importlib.import_module(name)
    for finder, name, ispkg
    in iter_namespace(myapp.plugins)
}

myapp.plugins.__path__ 指定给 iter_modules() 会导致它只查找该命名空间下的模块。例如,如果你安装了提供模块 myapp.plugins.amyapp.plugins.b 的发行版,则在这种情况下 discovered_plugins 将为

{
    'a': <module: 'myapp.plugins.a'>,
    'b': <module: 'myapp.plugins.b'>,
}

此示例使用子包作为命名空间包 (myapp.plugins),但也可以为此目的使用顶级包(例如 myapp_plugins)。如何选择要使用的命名空间是一个偏好问题,但建议不要将项目的顶级主包(在本例中为 myapp)设为插件的命名空间包,因为一个错误的插件可能会导致整个命名空间损坏,这反过来又会使你的项目无法导入。对于“命名空间子包”方法,插件包必须省略顶级包目录(在本例中为 myapp)的 __init__.py,并在命名空间子包目录(myapp/plugins)中包含命名空间包样式的 __init__.py。这也意味着插件需要显式地将包列表传递给 setup()packages 参数,而不是使用 setuptools.find_packages()

警告

命名空间包是一个复杂的功能,有几种不同的方法可以创建它们。强烈建议阅读打包命名空间包文档,并明确记录哪个方法更适合项目中的插件。

使用包元数据#

包可以具有入口点规范中描述的插件元数据。通过指定它们,包会宣布它包含特定类型的插件。支持这种类型插件的另一个包可以使用元数据来发现该插件。

例如,如果你有一个名为myapp-plugin-a的包,并且它在pyproject.toml中包含以下内容

[project.entry-points.'myapp.plugins']
a = 'myapp_plugin_a'

那么你可以使用importlib.metadata.entry_points()(或反向移植importlib_metadata >= 3.6适用于 Python 3.6-3.9)来发现并加载所有已注册的入口点

import sys
if sys.version_info < (3, 10):
    from importlib_metadata import entry_points
else:
    from importlib.metadata import entry_points

discovered_plugins = entry_points(group='myapp.plugins')

在此示例中,discovered_plugins将是类型importlib.metadata.EntryPoint的集合

(
    EntryPoint(name='a', value='myapp_plugin_a', group='myapp.plugins'),
    ...
)

现在可以通过执行discovered_plugins['a'].load()来导入你选择的模块。

注意

entry_pointsetup.py中的规范相当灵活,并且有很多选项。建议阅读入口点的整个部分。

注意

由于此规范是标准库的一部分,因此除了 setuptools 之外的大多数打包工具都提供对定义入口点的支持。