0%

深入 Python import 机制

对于 Python 的 import 机制,一直以来感觉都一知半懂,尤其是对于 Python 这种非常 freestyle 的操作,在项目组织上没有什么固定的目录结构可言,如果部署或者打包的时候发生 import 出错或者失败,有时候确实会丈二和尚摸不着头脑,充其量就改改 sys.path 就完事了。但是这样的实现无疑是 ugly 的,要想明白 import 的工作原理,还是得好好地啃啃硬骨头。

所以这篇 blog 就这么来了,内容会包括:

  • 模块和包的基本介绍
  • 默认的 import 流程和常见的 hack 手段
  • import 协议以及相关对象:finder、loader、importer 和 spec
  • 两种 import hook

模块 module 与 包 package

什么是模块

典型的,一个 .py 后缀文件即是 Python 的一个模块。在模块的内部,可以通过全局变量 __name__ 来获得模块名。模块可以包含可执行的语句,这些语句会在模块 初始化 的时候执行 —— 当所在模块被 import 导入时它们有且只会执行一次。

什么是包

根据目前 PEP420 的提案,目前的 Python 实际上是有两种包的存在:正规包regular Package) 以及 命名空间包Namespace package)。

要注意的是,Python 的 package 实际上都是特殊的 module :可以通过导入 package 之后查看 globals() 可知;实际上,任何带有 __path__ 属性的对象都会被 Python 视作 package 。

  • 正规包:
    在 Python 3.2 之前就已经存在了的,通常是以包含一个 __init__.py 文件的目录形式展现。当 package 被导入时,这个 __init__.py 文件会被 隐式 地执行。
  • 命名空间包:
    根据 PEP420 的定义,命名空间包是由多个 portion 组成的 —— portion 类似于父包下的子包,但它们物理位置上不一定相邻,而且它们可能表现为 .zip 中的文件、网络上的文件等等。命名空间包不需要 __init__.py 文件,只要它本身或者子包(也就是 portion)被导入时,Python 就会给顶级的部分创建为命名空间包 —— 因此,命名空间包不一定直接对应到文件系统中的对象,它可以是一个 虚拟 的 module 。

导入系统

模块中的 Python 代码可以通过 import(导入)操作访问另一个模块内的代码。import 语句时调起导入机制的常用方式,但不是唯一方式。importlib.import_module() 以及内置的 __import__() 函数都可以调起导入机制。

import 语句实际上结合了两个操作:

  1. 搜索操作:根据指定的命名查找模块
  2. 绑定操作:将搜索的结果绑定到当前作用域对应的命名上

import 的 search 操作实际上是带参调用 __import__() 函数,而函数的返回值会用在 import 语句的绑定操作上。

直接调用 __import__() 只会执行模块查找,以及如果找到的话就创建模块。这会有一定的副作用,比如导入父包和更新各式各样的缓存(包括 sys.modules),而且绑定操作只有 import 语句才会做得到。

其他调起导入机制的行为(比如 importlib.import_module())有可能会绕过 __import__() 并使用自定义的方法来实现导入。

当一个模块被首次导入时,Python 会搜索该模块,如果找到就创建一个 module 对象并初始化;如果位找到则抛出 ModuleNotFoundError 异常。至于如何找到这些模块,Python 定义了多种的 搜索策略 (search strategy),而这些策略可以通过 importlib 等提供的各类 hook 来修改和扩展。

根据 Python 3.3 的 changlog 可知目前导入系统已完全实现了 PEP302 的提案,所有的导入机制都会通过 sys.meta_path 暴露出来,不会再有任何隐式的导入机制。

去哪里找到模块/包

绝对导入和相对导入

Python 提供了两种导入机制:

  • relative import 相对导入
  • absolute import 绝对导入

其中,相对导入是 Python 2.5 之前的默认的导入方法,形式如下:

1
2
3
4
# 可以看到,使用相对导入时只能采取 from ... import ... 的形式
from .example import a
from ..example import b
from ...example import c

绝对导入也叫做完全导入,在 Python 2.5 之后被完全实现,同时在 PEP8 中也提倡使用完全导入,它的使用方式如下:

1
2
from pkg import foo
from pkg.moduleA import foo

值得注意的是,使用绝对导入,我们经常会遇到 —— 因为位置问题,Python 找不到相应的库文件从而抛出 ImportError 异常。
其中很常用的一个办法是,将当前工作目录添加到 sys.path 这个列表当中去(注意要在你实际 import 该模块或包之前)

模块搜索路径

当我们要导入一个模块(比如 foo )时,解释器首先会根据命名查找内置模块,如果没有找到,它就会去查找 sys.path 列表中的目录,看目录中是否有 foo.pysys.path 的初始值来自于:

  • 运行脚本所在的目录(如果打开的是交互式解释器则是当前目录)
  • PYTHONPATH 环境变量(类似于 PATH 变量,也是一组目录名组成)
  • Python 安装时的默认设置

当然,这个 sys.path 是可以修改的(正如上文提到的一种解决办法)。值得注意的是,如果当前目录包含有和标准库同名的模块,会直接使用当前目录的模块而不是标准模块。

扩展 —— 使用 .pth 文件扩展搜索路径

如果不想修改 sys.path 的同时又想扩展搜索路径,可以使用 .pth 文件。首先该文件内容很简单,只需要补充你要导入的库的路径(绝对路径),一行一个;然后将该文件放到 特定的位置 ,Python 在加载模块时,就会读取 .pth 文件中的路径。

那么这个所谓的 特定位置 是哪里呢?我们可以通过 site 模块的 getsitepackages 方法得到:

1
2
3
import site
# 不同的平台返回的结果不同,其结果是一个路径列表
site.getsitepackages()

在 Windows 下,该位置一般是对应环境(或虚拟环境)的 site-packages 目录。

深入 import 搜索

当然,上文主要是涉及默认的导入机制中搜索操作的具体表现,搜索操作的结果会加入到 sys.modules 中并进行绑定操作。实际上,这些操作在 Python 中有一套更为复杂而规范的流程,以便我们可以更好的扩展这套机制的同时尽可能地实现兼容性。

为了开始搜索,Python 需要被导入模块(或者包)的完全限定名(fully qualified name)。这个名称可能作为 import 语句的参数得到,或者是从函数 importlib.import_module()__import__() 的传参得到。

缓存 cache

在导入搜索开始前,会先检查 sys.modules ,它是导入系统的缓存,本质上是一个字典,如果之前已经导入过 foo.bar.baz,则将会包含 foofoo.bar 以及 foo.bar.baz 键,其对应的值为各自的 module 对象。

导入期间,如果在 sys.modules 找到对应的模块名的键,则取出其值,导入完成(如果值为 None 则抛出 ModuleNotFoundError 异常);否则就进行搜索操作。

sys.modules 是可修改的,强制赋值 None 会导致下一次导入该模块抛出 MoudleNotFoundError 异常;如果删掉该键则会让下次导入触发搜索操作。

注意,如果要更新缓存,使用 删除 sys.modules 的键 这种做法会有副作用,因为这样回导致前后导入的同名模块的 module 对象不是同一个。最好的做法应该是使用 importlib.reload() 函数。

查找器 finder 和加载器 loader

如果在缓存中找不到模块对象,则 Python 会根据 import 协议去查找和加载该模块进来。这个协议在 PEP320 中被提出,有两个主要的组成概念:finder 和 loader 。finder 的任务是确定能否根据已知的策略找到该名称的模块。同时实现了 finder 和 loader 接口的对象叫做 importer —— 它会在找到能够被加载的所需模块时返回自身。

Python 自带了一些默认的 finder 和 importer 。其中第一个知道 如何定位内置模块,第二个知道 如何定位 frozen 模块,第三个默认的 finder 会在 import path 中查找模块(即 path based finder)。

根据术语表,import path 是一个由文件系统路径或 .zip 文件组成的列表(也可以被扩展为任何可以定位的资源位置如 URL),被 path based finder(默认的元路径 finder)使用来导入模块。此列表通常来自 sys.path,但对于子包来说也可能是其父包的 __path__ 属性。

我们可以打印来看一下这三个 Importer 和 Finder :

1
2
3
4
5
6
import sys
import pprint
pprint.pprint(sys.meta_path)
# [<class '_frozen_importlib.BuiltinImporter'>,
# <class '_frozen_importlib.FrozenImporter'>,
# <class '_frozen_importlib_external.PathFinder'>]

finder 并不会真正加载模块。如果他能找到对应命名的模块,会返回一个 module spec,它实际上是 module 导入所需信息的封装,供后续导入机制使用来加载模块。

注意在 Python 3.4 之前 finder 会直接返回 loader 而不是 module spec,后者实际上已经包含了 loader 。

import hook

import hook 是用来扩展 import 机制的,它有两种类型:

  • meta hook
  • import path hook

meta hook 会在导入的最开始被调用(在查找缓存 sys.modules 之后),你可以在这里重载对 sys.path、frozen module 甚至内置 module 的处理。只需要往 sys.meta_path 添加一个新的 finder 即可注册 meta_hook 。

import path hook 会在 sys.path (或 package.__path__)处理时被调用,它们会负责处理 sys.path 中的条目。只需要往 sys.path_hooks 添加一个新的可调用对象即可注册 import path hook 。

元路径 meta_path

当无法从 sys.modules 中找到模块时,Python 会继续搜索 sys.meta_path 列表,列表中的 finder 会被依次用来查询是否知道如何处理这个命名的模块。

所有的 meta path finder 都必须实现 find_spec 方法(参考 importlib.abc.MetaPathFinder.find_spec),如果无法处理就返回 None;否则返回一个 spec 对象(即 importlib.machinery.ModuleSpec 的实例)。如果全部的 finder 都没有返回,将抛出 ModuleNotFoundError 异常并放弃导入。

find_spec(fullname, path, target=None)

以 foo.bar.baz 模块为例对 find_spec 进行说明

参数说明:

参数 说明 示例
fullname 被导入模块的完全限定名 foo.bar.baz
path 供搜索使用的路径列表,对于最顶级模块,这个值为 None;对于子包,这个值为父包的 __path__ 属性值 foo.bar.__path__
target 用作稍后加载目标的现有模块对象,这个值仅会在重载模块时传入 None

对于单个导入请求可能会多次遍历 meta_path,加入示例的模块都尚未被缓存,则会在每个 finder (以 mpf 命名)上依次调用

  • mpf.find_spec("foo", None, None)
  • mpf.find_spec("foo.bar", foo.__path__, None)
  • mpf.find_spec("foo.bar.baz", foo.bar.__path__, None)

Python 3.4 之后 finder 的 find_module() 已被 find_spec() 所替代并弃用。

import 加载机制

下面的代码简要说明了 import 加载部分的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
module = None
if spec.loader is not None and hasattr(spec.loader, 'create_module'):
# It is assumed 'exec_module' will also be defined on the loader.
# 假定 loader 中已经定义了 `exec_module` 模块
module = spec.loader.create_module(spec)
if module is None:
module = ModuleType(spec.name)
# The import-related module attributes get set here:
# 和模块导入相关联的属性在这个初始化方法中被设置
_init_module_attrs(spec, module)

if spec.loader is None:
if spec.submodule_search_locations is not None:
# namespace package
# 倘若这个模块是命名空间包
sys.modules[spec.name] = module
else:
# unsupported
# 不支持命名空间包
raise ImportError
elif not hasattr(spec.loader, 'exec_module'):
module = spec.loader.load_module(spec.name)
# Set __loader__ and __package__ if missing.
# 如果缺失 `__loader__` 和 `__package__` 属性则要补充
else:
sys.modules[spec.name] = module
try:
spec.loader.exec_module(module)
except BaseException:
try:
del sys.modules[spec.name]
except KeyError:
pass
raise
return sys.modules[spec.name]

以下是一些细节:

  • 在 loader 执行 exec_module 之前,需要将模块缓存在 sys.modules :因为模块可能会导入自身,这样做可以防止无限递归(最坏情况)或多次加载(最好情况)。
  • 如果加载失败,那么失败的模块会从 sys.modules 中被移除。任何已经存在的模块或者依赖但成功加载的模块都会保留 —— 这和重载不一样,后者即使加载失败也会保留失败的模块在 sys.modules 中。
  • 模块的执行是加载的关键步骤,它负责填充模块的命名空间。模块执行将会全权委托给 loader ,由 loader 决定如何填充和填充什么。
  • 创建出来并传递给 exec_module 执行的 module 对象可能和最后被 import 的 module 对象不一样。

loader 对象

loader 是 importlib.abc.Loader 的实例,负责提供最关键的加载功能:模块执行。它的 exec_module() 方法接受唯一一个参数 —— module 对象,它所有的返回值都会被忽略。

loader 必须满足以下条件:

  • 如果这个 module 是一个 Python module(和内置模块以及动态加载的扩展相区分),则 loader 应该在模块的全局命名空间(module.__dict__)中执行模块代码。
  • 如果 loader 不能执行模块,应该抛出 ImportError 异常。

Python 3.4 的两个变化:

  1. loader 提供 create_module() 来创建 module 对象(接受一个 module spec object 并返回 module object)。如果返回 None ,则由导入机制自行创建模块。因为 module 对象在模块执行前必须存在 sys.modules
  2. load_module() 方法被 exec_module() 方法替代,为了向前兼容,如果存在 load_module() 且未实现 exec_module, 导入机制才会使用 load_module() 方法。

module spec 对象

module spec 主要有两个作用:

  1. 传递 —— 可以在导入系统的不同组件,如 finder 和 loader 之间传递状态信息
  2. 模板(boilerplate)构建 —— 导入机制可以根据 module spec 执行模板加载操作,没有 module spec 则 loader 需要负责完成这个工作。

module spec 通过 module 对象的 __spec__ 属性得以公开,可以查看 ModuleSpec 获取更多信息。

1
2
3
>>> import requests
>>> requests.__spec__
ModuleSpec(name='requests', loader=<_frozen_importlib_external.SourceFileLoader object at 0x000002EE4EBBF7B8>, origin='C:\\Python37\\lib\\site-packages\\requests\\__init__.py', submodule_search_locations=['C:\\Python37\\lib\\site-packages\\requests'])

导入相关的模块属性

_init_module_attrs 步骤中,导入机制会根据 module spec 填充 module 对象(这个过程发生在 loader 执行模块之前)。

属性 说明
__name__ 模块的完全限定名
__loader__ 模块加载时使用的 loader 对象,主要是用于内省
__package__ 取代 __name__ 用于处理相对导入,必须设置!当导入包时,这个值和 __name__ 相同;当导入子包时,则为其父包名;为顶级模块时,应该为空字符串
__spec__ 导入时要使用的 module spec 对象
__path__ 如果模块为包,则必须设置!这个值为可迭代对象,如果没有进一步用途,可以为空,否则迭代结果应该为字符串
__file__ 可选值,只有内置模块可以不设置 __file__ 属性
__cached__ 为编译后字节码文件所在路径,它和 __file__ 的存在互不影响

在命名空间包出来之前,如果想实现命名空间包功能,一般是在包的 __init__.py 中修改其 __path__ 属性。随着 PEP420 的引入,命名空间包已经可以不需要 __init__.py 的这种操作了。

path-based-finder 基于元路径查找器

上文已经提到过,Python 默认自带了几个 meta path 的 finder ,其中之一就是 PathBasedFinder ,它负责搜索 import path 上的路径。

这个 finder 实际上并不知道如何进行 import ,它的工作只是遍历 import path 上的每一个条目,将它们关联到某个知道如何处理特定类型路径的 path entry finder(路径条目查找器)。

根据术语表,path entry finder 是由 sys.path_hook 列表中的可调用对象返回的(前提是它知道如何根据特定路径条目找到模块)。

可以将 PathEntryFinder 看作 PathBasedFinder 的具体实现。实际上,如果从 sys.meta_path 中移除了 PathBasedFinder ,则不会有任何 PathEntryFinder 被调用。

path entry finder 路径条目查找器

PathBasedFinder 会使用到三个变量,它们会提供给自定义导入机制的额外途径,包括:

  • sys.path
  • sys.path_hooks
  • sys.path_importer_cache

包的 __path__ 属性也会被使用。

sys.path 是一个字符串列表,提供了模块和包的搜索位置。它的条目可以来自于文件系统的目录、zip 文件或者其他潜在可以找到模块的“位置”(参考 site 模块)。

由于 PathBasedFinder 是一个 meta path finder ,所以必须实现了 find_spec() 方法。导入机制会通过调用这个方法来搜索 import path (通过传入 path 参数 —— 它是一个可遍历的字符串列表)。

find_spec() 内部,会迭代 path 的每个条目,并且每次都查找与条目相对应的 PathEntryFinder。但由于这个操作会很耗资源,因此 PathBasedFinder 会维持一个缓存 —— sys.path_importer_caceh 来存放路径条目到 finder 之间的映射(虽然是这样子命名,但它存放的确实是 finder 对象而不是 importer 对象)。那么只要条目找到过一次 finder 就不会重新再匹配(你可以手动移除缓存条目来达到再次强制匹配的目的)。

如果缓存中没有对应路径条目的键,则会迭代 sys.path_hooks 中的每个 可调用对象。这些可调用对象都接受一个 path 参数,并返回一个 PathEntryFinder 或者抛出 ImportError 异常。

如果遍历完整个 sys.path_hooks 的可调用对象都没有返回 PathEntryFinder,则 find_spec() 方法会在 sys.path_importer_cache 中存入 None 并返回 None ,表示 PathBasedFinder 无法找到该模块。

大致的流程如图所示:

PathEntryFinder 流程图

Path Entry Finder 协议

由于 PathEntryFinder 需要负责导入模块、初始化包以及为命名空间包构建 portion ,所以也需要实现 find_spec() 方法,其形式如下:

1
find_spec(fullname, target=None)

其中:

  • fullname: 模块的完全限定名
  • target:可选的目标模块

Python 3.4 之后 find_spec() 替代了 find_loader()find_module() ,后两者已被弃用。

注意,如果该模块是命名空间包的 portion ,为了向导入机制说明,PathEntryFinder 会将返回的 spec 对象中的 loader 设为 None 并将 submodule_search_locations 设置为包含这个 portion 的列表。

一个非常久远的例子

之前在阅读《Python 黑帽子: 黑客与渗透测试编程之道》的时候,里面提到了一个利用 GitHub 仓库作为木马远端控制的手段,木马程序通过 GitHub 交互来实现目标控制、更新木马以及上传被控端数据到 GitHub 仓库,其中最主要的是利用了 import hook 的技术(即 git_trojan 中的 GitImporter,是一个 meta path finder)。有兴趣的可以去 github 仓库查阅,仅供学习交流 😀。

参考