pkgutil 简介
pkgutil 是 Python 标准库中的一个模块,提供了用于处理 Python 包的工具函数。它的核心功能之一是 iter_modules() 函数,能够动态遍历和发现指定包路径下的所有子模块和子包。这一特性使其成为实现动态插件系统的选择之一。(之前也介绍过借助__init_subclass__()在子类继承时动态注册插件)
与手动遍历文件系统或使用第三方库相比,pkgutil 具有以下优势:
- 标准库原生支持:无需引入额外依赖
- 跨平台兼容:统一处理不同操作系统的路径差异
- 支持命名空间包:能够正确处理 PEP 420 定义的命名空间包
- 与导入系统紧密集成:返回的模块名可直接用于 importlib.import_module()
核心函数:iter_modules
iter_modules() 函数签名如下:- pkgutil.iter_modules(path=None, prefix='')
复制代码
- path:要搜索的路径列表,通常使用包的 __path__ 属性
- prefix:返回的模块名前缀,常用于构建完整的模块导入路径
该函数返回一个迭代器,每个元素是一个三元组 (module_info_finder, name, ispkg):
- module_info_finder:查找器对象(Python 3.6+ 为 ModuleInfo 实例)
- name:模块或包的名称(不含前缀)
- ispkg:布尔值,表示是否为包(含有 __init__.py 的目录)
实现动态插件系统
设计思路
一个典型的插件系统包含以下组件:
- 协议定义:使用 typing.Protocol 定义插件必须实现的接口
- 插件发现:使用 pkgutil.iter_modules() 自动发现所有插件包
- 插件加载:使用 importlib.import_module() 动态导入插件模块
- 插件验证:运行时检查插件是否满足协议要求
- 插件执行:调用插件方法执行具体任务
代码实现
首先定义插件协议:- # jobs/base.py
- from typing import Protocol, runtime_checkable
- @runtime_checkable
- class JobProtocol(Protocol):
- """插件协议定义"""
-
- def enabled(self) -> bool:
- """判断插件是否启用"""
- ...
-
- def run(self) -> bool:
- """执行插件任务"""
- ...
复制代码 @runtime_checkable 装饰器使得协议可以在运行时通过 isinstance() 进行检查。
写稿的时候想起来可以加个_runable()方法, 执行run()方法之前先检查是否满足可执行条件。
插件加载器实现:- # main.py
- import logging
- import pkgutil
- import importlib
- from types import ModuleType
- from jobs.base import JobProtocol
- logger = logging.getLogger(__name__)
- def load_jobs(package: str = "jobs") -> list[JobProtocol]:
- """动态加载指定包下的所有任务插件"""
- loaded_jobs: list[JobProtocol] = []
- # 导入目标包
- pkg = importlib.import_module(package)
-
- # 遍历子包
- for finder, name, ispkg in pkgutil.iter_modules(pkg.__path__, pkg.__name__ + "."):
- # 只处理子包
- if not ispkg:
- continue
-
- # 动态导入模块
- module = importlib.import_module(name)
-
- # 获取工厂函数并创建实例
- if hasattr(module, "job_factory"):
- job = module.job_factory()
-
- # 协议验证
- if isinstance(job, JobProtocol) and job.enabled():
- loaded_jobs.append(job)
-
- return loaded_jobs
复制代码 插件实现示例:- # jobs/jobs1/__init__.py
- from jobs.base import JobProtocol
- from .job import MyJob1
- def job_factory() -> JobProtocol:
- return MyJob1()
- # jobs/jobs1/job.py
- class MyJob1:
- def enabled(self) -> bool:
- return True
- def run(self) -> bool:
- print(f"{self.__class__.__name__} is running")
- return True
复制代码之所以每个插件放单独的package中,是想着如果插件功能复杂,单个文件的篇幅可能会极长,可以拆分到不同的文件中。每个插件也可以维护单独的配置加载方式。而且可以利用上 pkgutil 返回的 ispkg 。如果插件的功能简单,也可以写成单独的文件。
项目结构
- project/
- ├── main.py # 主程序入口
- ├── jobs/
- │ ├── __init__.py # 包初始化文件
- │ ├── base.py # 协议定义
- │ ├── jobs1/ # 插件1
- │ │ ├── __init__.py # 导出 job_factory
- │ │ └── job.py # 具体实现
- │ ├── jobs2/ # 插件2
- │ │ ├── __init__.py
- │ │ └── job.py
- │ └── jobs3/ # 插件3(可禁用)
- │ ├── __init__.py
- │ └── job.py
复制代码 运行结果示例
- 2026-03-01 21:13:26 - INFO - 开始加载任务...
- 2026-03-01 21:13:26 - INFO - 成功加载任务: jobs.jobs1
- 2026-03-01 21:13:26 - INFO - 成功加载任务: jobs.jobs2
- 2026-03-01 21:13:26 - INFO - 任务 jobs.jobs3 已禁用,跳过
- 2026-03-01 21:13:26 - WARNING - 任务 jobs.jobs4 未实现 JobProtocol 协议(缺少 enabled 或 run 方法)
- 2026-03-01 21:13:26 - INFO - 共加载 2 个任务
- 2026-03-01 21:13:26 - INFO - 开始执行任务...
- MyJob1 is running
- 2026-03-01 21:13:26 - INFO - 任务 MyJob1 执行完成,结果: True
- MyJob2 is running
- 2026-03-01 21:13:26 - INFO - 任务 MyJob2 执行完成,结果: True
- 2026-03-01 21:13:26 - INFO - 所有任务执行完毕
复制代码 实际应用注意事项
包结构规范
确保插件目录是规范的 Python 包:
- 每个插件包必须包含 __init__.py 文件
- 父包(jobs/)也应包含 __init__.py,确保 __path__ 属性正确设置
- 虽然 Python 3.3+ 支持命名空间包(无 __init__.py),但显式定义包结构更加健壮
错误处理策略
动态加载过程中存在多种潜在的失败点,需要逐一处理:- try:
- pkg = importlib.import_module(package)
- except ImportError as e:
- logger.error(f"导入包失败: {e}")
- return []
- for finder, name, ispkg in pkgutil.iter_modules(pkg.__path__, prefix):
- try:
- module = importlib.import_module(name)
- except Exception as e:
- logger.error(f"加载模块 {name} 失败: {e}")
- continue
-
- if not hasattr(module, "job_factory"):
- continue
-
- try:
- job = module.job_factory()
- except Exception as e:
- logger.error(f"实例化插件 {name} 失败: {e}")
- continue
复制代码 使用 logging 替代 print
生产环境中应使用 logging 模块:- import logging
- logging.basicConfig(
- level=logging.INFO,
- format="%(asctime)s - %(levelname)s - %(message)s",
- )
- logger = logging.getLogger(__name__)
复制代码 这提供了日志级别控制、时间戳、输出重定向等关键能力。
Protocol 运行时检查
typing.Protocol 配合 @runtime_checkable 装饰器支持运行时类型检查:- from typing import Protocol, runtime_checkable
- @runtime_checkable
- class JobProtocol(Protocol):
- def enabled(self) -> bool: ...
- def run(self) -> bool: ...
- # 检查实例是否满足协议
- if isinstance(job, JobProtocol):
- job.run()
复制代码 注意:运行时检查仅验证方法是否存在,不验证方法签名。如果参数类型不匹配,运行时仍会报错。
插件隔离与依赖管理
- 避免循环导入:插件模块不应导入主程序模块
- 延迟导入:插件内部的重量级依赖应在 run() 方法中导入,而非模块顶层
- 异常隔离:每个插件的执行应该相互独立,一个插件失败不应影响其他插件
- def run_jobs(jobs: list[JobProtocol]) -> None:
- for job in jobs:
- try:
- job.run()
- except Exception as e:
- logger.error(f"任务执行失败: {e}")
- # 继续执行其他任务
复制代码 插件顺序控制
如果插件执行顺序很重要,可以考虑以下策略:
- 使用插件名称前缀排序(如 jobs/01_init/、jobs/02_process/)
- 在协议中添加 priority() 方法
- 在插件元数据中定义依赖关系
性能考量
- iter_modules() 遍历文件系统,频繁调用可能影响性能
- 考虑在程序启动时一次性加载所有插件,后续使用缓存的插件列表
- 对于大量插件,可以考虑延迟加载(lazy loading)模式
安全性考虑
动态加载代码存在潜在安全风险:
- 仅从可信路径加载插件
- 在沙箱环境中运行不受信任的插件
- 限制插件的文件系统和网络访问权限
补充
代码示例
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |