创建和发现插件¶
Creating and discovering plugins
在创建 Python 应用程序或库时,通常你会希望能够通过 插件 提供自定义功能或额外的特性。由于 Python 包可以单独分发,你的应用程序或库可能希望自动 发现(discover) 所有可用的插件。
自动插件发现有三种主要方法:
Often when creating a Python application or library you'll want the ability to provide customizations or extra features via plugins. Because Python packages can be separately distributed, your application or library may want to automatically discover all of the plugins available.
There are three major approaches to doing automatic plugin discovery:
使用命名约定¶
Using naming convention
如果你应用程序的所有插件都遵循相同的命名约定,你可以使用 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-SQLAlchemy 和 Flask-Talisman 插件,那么 discovered_plugins
将会是:
{
'flask_sqlalchemy': <module: 'flask_sqlalchemy'>,
'flask_talisman': <module: 'flask_talisman'>,
}
使用命名约定来处理插件还可以让你查询 Python 包索引的 simple repository API,查找所有符合命名约定的包。
If all of the plugins for your application follow the same naming convention,
you can use pkgutil.iter_modules()
to discover all of the top-level
modules that match the naming convention. For example, Flask uses the
naming convention flask_{plugin_name}
. If you wanted to automatically
discover all of the Flask plugins installed:
import importlib
import pkgutil
discovered_plugins = {
name: importlib.import_module(name)
for finder, name, ispkg
in pkgutil.iter_modules()
if name.startswith('flask_')
}
If you had both the Flask-SQLAlchemy and Flask-Talisman plugins installed
then discovered_plugins
would be:
{
'flask_sqlalchemy': <module: 'flask_sqlalchemy'>,
'flask_talisman': <module: 'flask_talisman'>,
}
Using naming convention for plugins also allows you to query the Python Package Index's simple repository API for all packages that conform to your naming convention.
使用命名空间包¶
Using namespace packages
命名空间包 可以用来提供插件的放置约定,并且提供了一种执行发现的方法。例如,如果你将子包 myapp.plugins
设为命名空间包,那么其他 分发包 可以将模块和包提供给该命名空间。一旦安装,你可以使用 pkgutil.iter_modules()
来发现所有安装在该命名空间下的模块和包:
import importlib
import pkgutil
import myapp.plugins
def iter_namespace(ns_pkg):
# 在 iter_modules 中指定第二个参数 (prefix) 使得返回的名称是绝对名称,而不是相对名称。
# 这样可以让 import_module 正常工作,而无需对名称进行额外的修改。
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.a
和 myapp.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()
。
警告
命名空间包是一个复杂的特性,有多种不同的方法来创建它们。强烈建议阅读 打包命名空间包 文档,并清楚地记录你希望为项目插件采用的优选方法。
Namespace packages can be used to provide
a convention for where to place plugins and also provides a way to perform
discovery. For example, if you make the sub-package myapp.plugins
a
namespace package then other distributions can
provide modules and packages to that namespace. Once installed, you can use
pkgutil.iter_modules()
to discover all modules and packages installed
under that namespace:
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)
}
Specifying myapp.plugins.__path__
to iter_modules()
causes
it to only look for the modules directly under that namespace. For example,
if you have installed distributions that provide the modules myapp.plugins.a
and myapp.plugins.b
then discovered_plugins
in this case would be:
{
'a': <module: 'myapp.plugins.a'>,
'b': <module: 'myapp.plugins.b'>,
}
This sample uses a sub-package as the namespace package (myapp.plugins
), but
it's also possible to use a top-level package for this purpose (such as
myapp_plugins
). How to pick the namespace to use is a matter of preference,
but it's not recommended to make your project's main top-level package
(myapp
in this case) a namespace package for the purpose of plugins, as one
bad plugin could cause the entire namespace to break which would in turn make
your project unimportable. For the "namespace sub-package" approach to work,
the plugin packages must omit the __init__.py
for your top-level
package directory (myapp
in this case) and include the namespace-package
style __init__.py
in the namespace sub-package directory
(myapp/plugins
). This also means that plugins will need to explicitly pass
a list of packages to setup()
's packages
argument instead of using
setuptools.find_packages()
.
警告
Namespace packages are a complex feature and there are several different ways to create them. It's highly recommended to read the 打包命名空间包 documentation and clearly document which approach is preferred for plugins to your project.
使用包元数据¶
Using package metadata
包可以在 入口点 中描述插件的元数据。通过指定这些元数据,包声明它包含某种特定类型的插件。其他支持这种插件类型的包可以使用这些元数据来发现该插件。
例如,如果你有一个名为 myapp-plugin-a
的包,并且在它的 pyproject.toml
中包含以下内容:
[project.entry-points.'myapp.plugins']
a = 'myapp_plugin_a'
那么你可以通过使用 importlib.metadata.entry_points()
(对于 Python 3.6-3.9,使用 backport 的 importlib_metadata >= 3.6
)来发现并加载所有注册的入口点:
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()
来导入你选择的模块。
备注
setup.py
中的 entry_point
规范非常灵活,具有很多选项。建议阅读 entry points 部分的全部内容。
备注
由于这个规范是 标准库 的一部分,除了 setuptools 之外,大多数打包工具也提供了对定义入口点的支持。
Packages can have metadata for plugins described in the 入口点规范. By specifying them, a package announces that it contains a specific kind of plugin. Another package supporting this kind of plugin can use the metadata to discover that plugin.
For example if you have a package named myapp-plugin-a
and it includes
the following in its pyproject.toml
:
[project.entry-points.'myapp.plugins']
a = 'myapp_plugin_a'
Then you can discover and load all of the registered entry points by using
importlib.metadata.entry_points()
(or the backport
importlib_metadata >= 3.6
for 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')
In this example, discovered_plugins
would be a collection of type importlib.metadata.EntryPoint
:
(
EntryPoint(name='a', value='myapp_plugin_a', group='myapp.plugins'),
...
)
Now the module of your choice can be imported by executing
discovered_plugins['a'].load()
.
备注
The entry_point
specification in setup.py
is fairly
flexible and has a lot of options. It's recommended to read over the entire
section on entry points .
备注
Since this specification is part of the standard library, most packaging tools other than setuptools provide support for defining entry points.