Py学习  »  Python

深入了解Python的import机制

马哥Linux运维 • 2 年前 • 255 次点击  

1. 引言

Python中官方的定义为:Python code in one module gain access to the code in another module by the process of importing it.
在平常的使用中,我们一定会使用from xxx import xxx或是import xx这样的导包语句,假如你研究过Python中的包你就会发现,很多包中会包含__init__.py这样的文件,这是为什么呢?
这篇博文中,我们就从模块/包和import的加载、搜索机制,参考最新的Python3.9.1的文档对它一探究竟。

2. 模块module和包package

2.1 什么是模块?

模块的定义:用来从逻辑(实现一个功能)上组织Python代码(变量、函数、类),本质就是一个*.py文件。文件是物理上组织方式module_name.py,模块是逻辑上组织方式module_name
其实,一个.py后缀的文件就是Python的一个模块。
在模块的内部,可以通过一个全局变量__name__来获得模块名,模块可以包含可执行的语句,这些语句在模块初始化的时候执行——当所在模块被import导入时,它们有且只有执行一次

2.2 什么是包?

包的定义:Python 只有一种模块对象类型,所有模块都属于该类型,无论模块是用 Python、C 还是别的语言实现。为了帮助组织模块并提供名称层次结构,Python 还引入了包的概念。
你可以把包看成是文件系统中的目录,并把模块看成是目录中的文件,但请不要对这个类似做过于字面的理解,因为包和模块不是必须来自于文件系统。为了方便理解本文档,我们将继续使用这种目录和文件的类比。与文件系统一样,包通过层次结构进行组织,在包之内除了一般的模块,还可以有子包。
要注意的一个重点概念是所有包都是模块,但并非所有模块都是包。或者换句话说,包只是一种特殊的模块。特别地,任何具有 __path__ 属性的模块都会被当作是包。
从Python的文档中,我们得知:Python中的包包括:常规包和命名空间包。
  • 常规包:
    Python 定义了两种类型的包,常规包 和 命名空间包。常规包是传统的包类型,它们在 Python 3.2 及之前就已存在。常规包通常以一个包含 __init__.py 文件的目录形式实现。当一个常规包被导入时,这个 __init__.py 文件会隐式地被执行,它所定义的对象会被绑定到该包命名空间中的名称。__init__.py 文件可以包含与任何其他模块中所包含的 Python 代码相似的代码,Python 将在模块被导入时为其添加额外的属性。
    例如,以下文件系统布局定义了一个最高层级的 parent 包和三个子包:
parent/ __init__.py one/ __init__.py two/ __init__.py three/ __init__.py
导入 parent.one 将隐式地执行 parent/__init__.py 和 parent/one/__init__.py。后续导入 parent.two 或 parent.three 则将分别执行 parent/two/__init__.py 和 parent/three/__init__.py
  • 命名空间包
    命名空间包是由多个 部分 构成的,每个部分为父包增加一个子包。各个部分可能处于文件系统的不同位置。部分也可能处于 zip 文件中、网络上,或者 Python 在导入期间可以搜索的其他地方。命名空间包并不一定会直接对应到文件系统中的对象;它们有可能是无实体表示的虚拟模块。
    命名空间包的 __path__ 属性不使用普通的列表。而是使用定制的可迭代类型,如果其父包的路径 (或者最高层级包的 sys.path) 发生改变,这种对象会在该包内的下一次导入尝试时自动执行新的对包部分的搜索。
    命名空间包没有 parent/__init__.py 文件。实际上,在导入搜索期间可能找到多个 parent 目录,每个都由不同的部分所提供。因此 parent/one 的物理位置不一定与 parent/two 相邻。在这种情况下,Python 将为顶级的 parent 包创建一个命名空间包,无论是它本身还是它的某个子包被导入。

2.3 导入系统

一个 module 内的 Python 代码通过 importing 操作就能够访问另一个模块内的代码。 import 语句是发起调用导入机制的最常用方式,但不是唯一的方式。 importlib.import_module() 以及内置的 __import__() 等函数也可以被用来发起调用导入机制。
import 语句结合了两个操作;它先搜索指定名称的模块,然后将搜索结果绑定到当前作用域中的名称。 import语句的搜索操作定义为对 __import__() 函数的调用并带有适当的参数。 __import__() 的返回值会被用于执行 import 语句的名称绑定操作。请参阅 import 语句了解名称绑定操作的更多细节。
对 __import__() 的直接调用将仅执行模块搜索以及在找到时的模块创建操作。不过也可能产生某些副作用,例如导入父包和更新各种缓存 (包括 sys.modules),只有 import 语句会执行名称绑定操作。
当 import 语句被执行时,标准的内置 __import__() 函数会被调用。其他发起调用导入系统的机制 (例如 importlib.import_module()) 可能会选择绕过 __import__() 并使用它们自己的解决方案来实现导入机制。
当一个模块首次被导入时,Python 会搜索该模块,如果找到就创建一个 module 对象 1 并初始化它。如果指定名称的模块未找到,则会引发 ModuleNotFoundError。当发起调用导入机制时,Python 会实现多种策略来搜索指定名称的模块。这些策略可以通过使用使用下文所描述的多种钩子来加以修改和扩展。
在 3.3 版更改: 导入系统已被更新以完全实现 PEP 302 中的第二阶段要求。不会再有任何隐式的导入机制 —— 整个导入系统都通过 sys.meta_path 暴露出来。此外,对原生命名空间包的支持也已被实现 。

3. 模块/包的位置

3.1 绝对导入和相对导入

Python提供了两种导入机制:
  • 相对导入
  • 绝对导入
相对导入的方法在Python2.5之前的版本较为常见,现在Python3中的导入方式均为完全导入
from threading import Threadfrom multiprocessing.pool import Pool
使用绝对导入方式也会导致一些问题,当我们导入本地目录的模块时,Python经常会找不到相应的库文件而抛出ImportError异常。解决这样的问题最为简单的是将本地目录添加到sys.path列表中去,在pycharm中可以对文件夹右键选择Mark Directory as->Sources Root

3.2 模块搜索路径

当我们要导入一个模块happy,解释器首先会根据命名规则查找内置模块,如果没有找到,就会去查找sys.path列表中的目录,看目录中是否有happy.py
sys.path初始值来自于:
  • 运行脚本的当前目录
  • Python的环境变量PYTHONPATH
  • Python安装的默认目录
Warning:一般来说,不要把模块名不要设置成和标准库同名。
当模块名和标准库同名时,会直接使用当前目录的模块而不是标准模块。

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

如果不想修改sys.path的同时又想要扩展搜索路径,可以使用.pth文件。只需要补充要导入的库的绝对路径,一行一个;然后将文件放到特定位置,Python在加载模块的时候,就会读取.pth文件中的路径。
特定位置路径:
  • Windows:对应安装位置或Anaconda环境中的site-packages位置
  • Linux:通过site.getsitepackages()查看
import siteprint(site.getsitepackages())# 输出['/Users/gray/anaconda3/anaconda3/envs/python-develop/lib/python3.7/site-packages']

4. 深入 import 搜索

当然,上文主要是涉及默认的导入机制中搜索操作的具体表现,搜索操作的结果会加入到 sys.modules 中并进行绑定操作。实际上,这些操作在 Python 中有一套更为复杂而规范的流程,以便我们可以更好的扩展这套机制的同时尽可能地实现兼容性。
为了开始搜索,Python 需要被导入模块(或者包)的完全限定名(fully qualified name)。这个名称可能作为 import 语句的参数得到,或者是从函数 importlib.import_module() 或 __import__() 的传参得到。

4.1 缓存 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() 函数。

4.2 查找器 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 :
import sysimport pprintpprint.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 。

4.3 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 。

4.4 元路径 meta_path

当无法从 sys.modules 中找到模块时,Python 会继续搜索 sys.meta_path 列表,列表中的 finder 会被依次用来查询是否知道如何处理这个命名的模块。
所有的 meta path finder 都必须实现 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() 所替代并弃用。

5. import 加载机制

下面的代码简要说明了 import 加载部分的过程:
module = Noneif 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 ImportErrorelif 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 raisereturn sys.modules[spec.name]
以下是一些细节:
  • 在 loader 执行 exec_module 之前,需要将模块缓存在 sys.modules :因为模块可能会导入自身,这样做可以防止无限递归(最坏情况)或多次加载(最好情况)。
  • 如果加载失败,那么失败的模块会从 sys.modules  中被移除。任何已经存在的模块或者依赖但成功加载的模块都会保留 —— 这和重载不一样,后者即使加载失败也会保留失败的模块在 sys.modules 中。
  • 模块的执行是加载的关键步骤,它负责填充模块的命名空间。模块执行将会全权委托给 loader ,由 loader 决定如何填充和填充什么。
  • 创建出来并传递给 exec_module 执行的 module 对象可能和最后被 import 的 module 对象不一样。

5.1 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() 方法。

5.2 module spec 对象

module spec 主要有两个作用:
  1. 传递 —— 可以在导入系统的不同组件,如 finder 和 loader 之间传递状态信息
  2. 模板(boilerplate)构建 —— 导入机制可以根据 module spec 执行模板加载操作,没有 module spec 则 loader 需要负责完成这个工作。
module spec 通过 module 对象的 __spec__ 属性得以公开,可以查看 ModuleSpec 获取更多信息。
>>> import requests>>> requests.__spec__ModuleSpec(name='requests', loader=<_frozen_importlib_external.sourcefileloader object at class="code-snippet__number" style="outline: 0px;max-width: 1000%;">0x000002EE4EBBF7B8>, origin='C:\\Python37\\lib\\site-packages\\requests\\__init__.py', submodule_search_locations=['C:\\Python37\\lib\\site-packages\\requests'])

5.3 导入相关的模块属性

在 _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 的这种操作了。

6. 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 被调用。

6.1 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 无法找到该模块。

6.2 Path Entry Finder 协议

由于 PathEntryFinder 需要负责导入模块、初始化包以及为命名空间包构建 portion ,所以也需要实现 find_spec() 方法,其形式如下:
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 的列表。
原文接:https://blog.csdn.net/u011130655/article/details/113018457

文章转载:Python编程学习圈
(版权归原作者所有,侵删)

点击下方“阅读原文”查看更多

Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/123860
 
255 次点击