QTA自动化测试 [QTA] Python Import Hooks 的实现和应用

匿名 · 2019年05月23日 · 987 次阅读

关于 Python

1991 年,第一个 Python 解释器诞生,历经快 30 年的发展,Python 已成为当今大学中教学的首选语言,在统计、AI 编程、脚本编写、系统测试等领域均排名第一,并当选 2018 年年度编程语言,足见其流行程度。

Python 不仅拥有非常强大的标准库,更是拥有海量的第三方模块,这跟 Python 高度的可扩展性是密不可分的。如果说把一个 Python 程序比作一栋建筑的话,那么模块就是组成这栋建筑的砖块,而模块的导入就是一块块的砖砌成一栋完整建筑的过程。在 Python 众多特性中,有一个特性隐秘而强大,它就是 Python 的 import hook 机制(PEP302 New Import Hooks)。利用它,我们可以接管 Python 的模块导入流程,实现非常强大的自定义功能,下面是一些知名的 Python 库使用 import hook 的应用:

  • pytest:大名鼎鼎的 Python 测试框架,可以直接使用 assert 进行断言,并且在断言失败时能够输出更加详细的错误信息。
  • Flask:Flask 实现了插件库的统一入口,例如我们安装了 Flask_API 这个库,我们可以直接通过 import flask.ext.api 引用它。
  • MacroPy:Python 的宏语法实现,在 Python 里实现了 case class, pattern matching, 尾递归优化, Linq 等等酷炫的特性。
  • Py2exe、Pyinstaller:可以直接将你的 Python 程序和 Python 运行环境进行打包,使得 Python 程序可以在没有安装 Python 的环境中运行,也可以作为一个独立文件方便传递和管理。

作为 Python 的一项 “高级” 特性,我们日常的编码一般很少会接触到,大多数时候它都隐藏在 Python 的一些第三方库中。但是当你深入了解它之后,你就会感叹它的强大。笔者也是因为需要实现一个 Python 程序动态插桩的框架而接触到 Python import hook 的,并最终通过该特性实现了自己的需求。下面就让我们一起走进 Python import hook 的世界。

一、模块导入过程

Python 的模块导入机制被设计为可扩展的,其主要实现就是 import hook(导入钩子)。导入钩子分为两种:meta hooks(元钩子) 和 import path hooks(导入路径钩子)。当程序尝试导入一个模块时,导入机制首先在 sys.modules 缓存中查找,如果没有对应的模块缓存,就会依次调用 sys.meta_path 中的 finder 对象,即调用导入协议来查找和加载模块,此时任何其他导入过程都还没有被执行,这允许 meta hooks 重载 sys.path 过程、冻结模块甚至内置模块。 meta hooks 的注册是通过向 sys.meta_path 添加新的查找器对象。import path hooks 是作为 sys.path (或 package.__ path __) 过程的一部分,在遇到它们所关联的路径项的时候被调用。import path hooks 的注册是通过向 sys.path_hooks 添加新的可调用对象。

Python 的导入协议包含了三个概念:

  • 查找器(finder): 决定自己是否能够通过运用其所知的任何策略找到相应的模块。必须实现find_spec()方法 (Python3.4 以后推荐的方法) 或者find_module()方法。如果 finder 可以查找到模块,则会返回一个 moduleSpecs 对象(对应find_spec()方法)或 loader 对象 (对应find_module()方法),没有找到则返回 None。
  • 加载器(loader): 负责加载模块,它必须实现一个load_module()的方法,在 Python 3.4 中,修改为实现exec_module()create_module()方法
  • 导入器(importer): 实现了 finder 和 loader 这两个接口的对象称为导入器。

使用 Gradle 构建的,在模块的 build.gradle 里配置:
moduleSpec 对象 (PEP 451),在 Python3.4 中引入,该对象记录了模块的位置、导入器等相关信息,包含了比 loader 对象更详细的信息。

二、sys.meta_path

在 Python3.6 中,sys.meta_path 存储着内置的 3 个 finder 对象

>>> import sys
>>> sys.meta_path
[<class '_frozen_importlib.BuiltinImporter'>, 
 <class '_frozen_importlib.FrozenImporter'>, 
 <class '_frozen_importlib_external.PathFinder'>]
  • _frozen_importlib.BuiltinImporter 知道如何导入内置模块,例如 sys 模块
  • _ frozen_importlib.FrozenImporter 知道如何导入冻结模块,例如__ hello __模块
  • _frozen_importlib_external.PathFinder 知道如何导入存在于 import path(导入路径) 中的模块,例如 socket 模块

下面两图分别为 Python3.4 之前的流程和 Python3.4 之后推荐的流程。Python3.4 引入了 moduleSpec 的概念,并且将之前的 load_module 步骤拆分为更详细的多个步骤。目前为止,还保持了向后兼容,所以旧的代码在新版本的 Python 中是可以正常运行的。

Python3.4 之前的导入流程:
Python3.4之前的导入流程
Python3.4 及之后推荐的导入流程:

Python3.4及之后推荐的导入流程
在熟悉了 Python 的导入流程之后,我们就可以向 sys.meta_path 中加入自定义的查找器和导入器,从而实现对模块导入流程的控制,实现自己需要的功能。例如一个简单的例子,在导入模块时打印出模块信息

>>> import sys
>>> class Watcher(object):
    @classmethod
    def find_spec(cls, name, path, target=None):
        print(name, path, target)
        return None
sys.meta_path.insert(0, Watcher)
import urllib.parse
>>> sys.meta_path.insert(0, ImportWatcher)
>>> import socket
Importing socket None None
Importing _socket None None
Importing selectors None None
Importing math None None
Importing select None None

又或者可以让 python 在导入第三方模块时自动安装缺少的库。

import sys
import subprocess
import importlib.util
class AutoInstall(object):
    _loaded = set()
    @classmethod
    def find_spec(cls, name, path, target=None):
        if path is None and name not in cls._loaded: 
            cls._loaded.add(name) 
            print("Installing", name)
            try:
                out = subprocess.check_output(
                    [sys.executable, '-m', 'pip', 'install', name])
                return importlib.util.find_spec(name) 
            except Exception as e:
                print("Failed")
         return None
sys.meta_path.append(AutoInstall)

还有一些更加实用和强大的应用场景,比如上传 Python 程序的审计信息、Python 程序性能监控、模块的懒加载、将配置文件当做一个 python 的模块导入等等。文章开头提到的 pytest 的例子中,pytest 使用 import hook 拦截了 Python 标准的模块导入流程,当 pytest 检测到导入的模块是一个测试用例文件时,pytest 首先会使用 Python 内置的 ast 模块将目标文件的源码解析成 AST(语法树),然后在语法树中查找并重写了 assert 语句,使得在 assert 失败时,能够输出更加详细的调试信息。

三、sys.path_hooks

在 Python3.6 中,sys.path_hooks 存储着内置了 2 个可调用对象或函数

>>> import sys
>>> sys.path_hooks
[<class 'zipimport.zipimporter'>, 
<function FileFinder.path_hook.<locals>.path_hook_for_FileFinder at 0x10ab5a7b8>]
  • zipimport.zipimporter 可以用来处理 zip 包类型的路径
  • FileFinder.path_hook..path_hook_for_FileFinder 可以用来处理普通的文件目录

早期的 Python 开发者们希望寻求一种能够直接从 Zip 包中导入 Python 模块的方法,要实现这个功能并不难,只需要对 import.c 进行少量的修改就可以实现了,但是如何将这个功能做到通用并且易于扩展呢?最初的设计方案是允许 sys.path 列表加入非字符串类型的对象,这些对象会实现必要的方法来支持模块的导入,但是这种方案有两个缺点:

  • 破坏了原有代码对 sys.path 中只包含字符串对象的假设
  • 和 PYTHONPATH 环境变量不兼容

后来 Jython 实现了一种妥协的方案,允许 sys.path 中加入字符串类型的子类,在子类中实现导入器协议,但这种方案实在无法被认为是一种 “优雅的实现”。再后来,一种更为合理的设计被采用了,也就是我们这里讲到的 sys.path_hooks,其核心思想来自于 McMillan 的 iu.py。

Python import path hooks 作用于 sys.meta_path 中的最后一个对象 PathFinder。PathFinder 也是一个 finder 对象,这意味着它也实现了find_spec()方法。当有新的搜索需求到达 PathFinder 时,它会启动两层循环:在外层循环中,PathFinder 会迭代搜索路径sys.path,如果sys.path_importer_cache中缓存了当前搜索路径对应的 finder 对象,则直接从缓存从取出返回,如果缓存中不存在,则启动内层循环,迭代sys.path_hooks并将当前搜索路径作为参数传递给sys.path_hooks中的可调用对象,如果当前项不能处理该路径,就必须抛出 ImportError,如果可以,则生成一个 finder 对象将其写入sys.path_importer_cache 缓存中并返回此结果。在外层循环中,对返回的 finder 对象,尝试调用find_spec()方法获取 moduleSpec 对象,如果返回不为 None 的,则将 moduleSpec 返回,否则继续执行外层循环。

>> sys.path_importer_cache
{'/': FileFinder('/'),
 '/usr/local/lib/python3.6': FileFinder('/usr/local/lib/python3.6'),
 '/usr/local/lib/python3.6/collections': FileFinder('/usr/local/lib/python3.6/collections'),
 '/usr/local/lib/python3.6/encodings': FileFinder('/usr/local/lib/python3.6/encodings'),
 '/usr/local/lib/python3.6/lib-dynload': FileFinder('/usr/local/lib/python3.6/lib-dynload'),
 '/usr/local/lib/python3.6/site-packages': FileFinder('/usr/local/lib/python3.6/site-packages'),
 '/usr/local/lib/python36.zip': None}

PathFinder导入模块流程
Python import path hooks 可以用来扩展支持从不同的路径类型中导入模块,例如默认的 zipimporter 可以直接从 zip 包中导入模块,我们也可以通过自定义的 path hooks 实现从远程文件服务器、redis 缓存、mysql 数据库等各种路径类型中导入模块。从远程文件服务器导入模块的实现可以参考python3-cookbook中的例子,这里就不再赘述。

四、site 模块

上面已经讲到了 Python import hook 的基本原理和应用,我们可以使用 import hook 实现非常强大的功能,但是笔者在实际的项目需求中还遇到了另外一个问题,我们需要应用源码中显式地将我们自定义的 hook 注册到 sys.meta_path 或者是 sys.path_hooks 中才能生效,而在某些应用场景中,我们不希望侵入应用源码,那么是否有方法可以在 Python 解释器启动后就自动开始执行 import hook 的操作呢?答案是有的,就是这里提到的site 模块

在 Python 解释器的初始化阶段,会自动导入 site 模块(除非使用 -S 参数显式关闭导入),site 模块的主要作用是补充 sys.path 路径,协助 python 预配置第三方模块目录,并会尝试导入 sitecustomize 和 usercustomize 模块。从模块名我们可以大致猜测出,这两个模块的目的为了自定义站点和用户相关的配置,但其实我们可以在这两个模块中执行任意的 python 代码,当然也可以将我们的自定义 hook 注册到 sys.meta_paths 和 sys.path_hooks 中。

以 Newrelic 为例,Newrelic 是一个性能监控的工具,支持 Python 语言,当安装好它的 newrelic-python-agent 之后,直接使用

$ newrelic-admin run-program python hello.py

就可以启动目标程序并对目标程序进行性能监控了,它的实现方式就是在 newrelic-admin 程序中将 newrelic-python-agent 自定义的 sitecustomize.py 所在的目录加入 PYTHONPATH 中,当被监控程序 python hello.py 启动时,就会加载 newrelic 中注册的 hook 并将性能监控的逻辑注入的程序中。

感兴趣的同学可以加入 QQ 群和公众号交流


如果你想要了解更多资讯,欢迎关注我们的微信公众号😀 我们会定时向大家推送团队同学分享的经验文章哦。

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册