如何在 Python 实现程序加载插件(动态加载模块文件,插件化开发)
内容概要:如何在python实现程序加载插件(动态加载模块文件,插件化开发)
初发布于如何在python实现程序加载插件(动态加载模块文件,插件化开发) - 知乎 (zhihu.com) 发布于 2022.06.19 10:14
后发布于项目FSMargoo/one-day-one-point: 每天一个技术点 (github.com) 发布于 2023.7.15
前言
PyCharm,vscode 都可以通过添加插件拓展软件功能
Minecraft,Terraria,Don't Starve 都可以通过添加mod拓展游戏玩法
Python 模块的格式就是 .py(或者 .pyd),这对于我们来说无疑是一种便利。那么究竟如何让 python 程序有扫描并加载插件(额外模块)的功能呢
本文中 “插件”,“动态加载的模块”,“额外模块” 同意
我们将探究以下问题
- 动态加载模块文件
- 导入指定位置的模块(相对位置或绝对位置)
- 让程序找到要加载的模块
一.如何动态加载模块文件
1.1 使用 import 关键字(无法实现)
用 import 和 from 关键字加载模块,是我们最常用的一种方式,但是关键字后面的模块名不能是变量,是已经被确定下来的常量
用诸如 pyinstaller 或者 nuitka 工具打包的话,也是不允许这些在关键字后面的模块名空着
1.2 使用 import 函数(官方不建议使用)
__import__函数(python 官方文档)
import 实际上是调用这个内置函数,不建议使用,官方建议使用importlib.import_module
1.3 使用 importlib 模块
importlib --- import 的实现(python 官方文档)
importlib.import_module(name, package=None) 简单好用,两个参数,一个指定包,一个指定模块
指定 package 之后,name 可以使用诸如 ".aaa.aaa" 的方式相对导入
实测程序在使用 nuitka 和 pyinstaller 打包后依然可以正常调用插件
值得注意的是,如果要使用相对路径,要注意执行二进制文件 (如.exe 文件) 的位置是相对路径的出发点
二.导入指定位置的模块
目录结构
src
├package
│├module1.py
│└module2.py
├inside
│└__main__2.py
└__main__1.py
2.1 导入同层或者下层目录的模块
当 __main__1.py 是入口文件
import importlib
# 进行了返回值的类型标注,变量名随意
# module1 在同层的叫做package的包里面,可以直接导入
module1 = importlib.import_module("package.module1")
# module2 在同层的叫做package的包里面,可以直接导入
module2 = importlib.import_module(".module2", package="package")
# __main__2 在同层的叫做inside的包里面,可以直接导入
__main__2 = importlib.import_module("inside.__main__2")
2.2 导入上层位置的模块
当 __main__2.py 作为入口文件
2.2.1 from import 关键字(无法解决)
如果在 __main__2.py 使用
from ..package import module1
会报错 ImportError: attempted relative import with no known parent package
因为只有在一个包中才能使用相对导入,__main__2.py 是主程序而不是包, 这样使用是不合法的
2.2.2 sys.path
sys.path 就像 Windows 的系统变量一样,是 Python 解释器搜索包的位置
所以我们只需要在 sys.path 这个列表里面加入上层目录就可以使用importlib.import_module 加载模块了,这个上层目录可以使用 ".." ,也可以使用绝对路径
(sys.path 默认有 "." 这就是为什么导入同层的包无需添加)
以下是示例
import sys
# 相对路径
sys.path.append("..")
# 绝对路径
实际测试上面那种写法在打包后可能失效,所以推荐使用下面这种写法
sys.path.append(os.path.join(sys.path[0], ".."))
# sys.path[0] 是入口文件的运行位置
2.2.3 示例
当 __main__2.py 是入口文件
import importlib
# 将上层目录添加到加载路径内
sys.path.append("..")
module1 = importlib.import_module("package.module1")
module2 = importlib.import_module(".module2", package="package")
__main__1 = importlib.import_module("__main__1")
三.让程序找到要加载的插件
方法多种多样,管理文件的模块 Python 已经有内置模块实现了,如 os.path,pathlib
这里就可以做你的插件规范了,
你可以规定你的插件必须是 .zip 压缩包,里面放着json格式的元数据和 .py 后缀的插件主体文件
最基础的加载一个插件的流程为
- 找到放着代码的插件主体文件
- 将路径转换为包名和模块名的形式
- 传入importlib.import_module(name, package=None)
本文为了方便实现,做一个简单的插件规范:只需要放 .py 后缀的插件主体文件到存放插件的目录就是一个合法的插件
对应流程为
- 扫描指定插件文件夹下的所有文件
- 过滤出我们想要的 .py 后缀的插件主体文件
- 转换为可以传入 importlib.import_module() 参数的形式
import os
# 第一步(读取)
# 读取path目录下的全部的文件和文件夹
things_in_plugin_dir: list = os.listdir(path)
# 第二步(过滤)和第三步(转换)
def pick_module(name):
if name.endswith(plugin_suffix): # 检查文件名后缀是否是.py -> 过滤
return name.split["."](0) # 后缀是.py 就提取文件名 -> 转换
else:
return "" # 后缀不是.py 就把这项置空
# 挑选出.py 为后缀的文件
files_in_plugin_dir: list = map(pick_module, things_in_plugin_dir)
# 这里也给出使用三元表达式和 lambda 的写法
def pick_module2(name):
return map(
lambda file_name: file_name.split(".")[0] # 后缀是.py 就提取文件名
if file_name.endswith(plugin_suffix) # 检查文件名后缀是否是.py
else "", # 后缀不是.py 就把这项置空
things_in_plugin_dir,
)
# files_in_plugin_dir: list = pick_module2(path)
# 去除列表中的空值
files_in_plugin_dir: list = list(filter(None, files_in_plugin_dir))
最终示例
该示例实现了扫描插件,加载插件
插件规范: .py 后缀的插件主体文件放到..src.plugins
就是一个合法的插件
目录结构
src
├plugins
│├module1.py
│└module1.py
└main_program
└__main__.py
其中 module1.py 和 module2.py 是两个插件
module1.py 文件中的内容
# plugins/module1.py
version = "v1.0.0"
def test():
print("this is a test func")
class testClass():
def __init__():
print("this is a test class")
def test():
print("this is a test func in class")
main.py 为入口文件
# src/main.py
import os
import importlib
import traceback
from types import ModuleType
loaded_plugins: dict[ModuleType] = {} # 这个字典用于保存加载好的插件对象
# 将上层目录的 package 文件夹添加到 sys.path 中,可以直接通过文件名导入
sys.path.append(os.path.join(sys.path[0], "..", "package"))
# 常量设置
plugin_suffix = "py" # 插件后缀为 py
path = os.path.join("..", "package") # 设置插件文件夹
# 读取该目录下的的文件和文件夹
things_in_plugin_dir = os.listdir(path)
def pick_module(name):
if name.endswith(plugin_suffix): # 检查文件名后缀是否是.py
return name.split["."](0) # 后缀是.py 就提取文件名
else:
return "" # 后缀不是.py 就把这项置空
# 挑选出.py 为后缀的文件
files_in_plugin_dir: list = map(pick_module, things_in_plugin_dir)
# 去除列表中的空值
files_in_plugin_dir: list = list(filter(None, files_in_plugin_dir))
# 加载插件
for name in files_in_plugin_dir:
try:
loaded_plugins[name] = importlib.import_module(f"{name}")
# 插件缺少依赖(ImportError 包括 ModuleNotFoundError)
except ModuleNotFoundError:
traceback.print_exc() # 输出报错信息用于排错
continue
except ImportError:
traceback.print_exc()
continue
except Exception as e:
print(f"A problem:{e}")
traceback.print_exc()
continue
# 这时你就得到了一个装着插件对象的字典
# 只需要使用 loaded_plugins[文件名] 就能调用对应插件
# 测试加载的插件
# 比如此时我要使用 module1 的 test 方法,只需要
loaded_plugins["module1"].test()
# 预期输出:this is a test func
# 比如此时我要实例化 module1 的 testMain 类,只需要
instance = loaded_plugins["module1"].testClass()
# 预期输出:this is a test class
instance.test()
# 预期输出:this is a test func in class
# 比如此时我要输出 module1 的 version 属性,只需要
print(loaded_plugins["module1"].version)
# 预期输出:v1.0.0
补充
具有插件加载功能的库/软件:
HowieHz/hpyculator: high extensibility calculator base on python (github.com)
该项目中的实现plugin_manager.py at main · HowieHz/hpyculator (github.com)