摘要
在 Python 语言中 import
指令用以将其他代码块导入,使开发者不用重新发明轮子。通常情况下,当使用 绝对引用
导入标准库中的模块代码时,import 指令工作很顺畅;这是得益于 Python 安装过程中已将标准库加入其默认搜索路径列表。但是当开发结构相对复杂的应用或可复用程序包时,import 指令各种匪夷所思的错误,常常让开发者无所适从。这是因为 Python 的 Import 过程,尤其是搜索过程与文件目录结构深度绑定。Python Import 过程中的各种诡异的行为,都与搜索过程密切相关。
前提
本文所有资料与实验均基于 python 3,详细的 python 版本为 3.11.6。与此前的版本相比,Python 在 3.5 版本引入了 Multi-phase extension module initialization
(# PEP 489),与之相关的还有 A ModuleSpec Type for the Import System
(# PEP 451)。
bash
❯ python --version
Python 3.11.6
同时,本文多模块共享库的相关内容,假设读者对 Python 代码编译或 Cython 开发有一定了解。
模块、包和命名空间
Python 的模块是代码的基本单元,Cpython 解释器将一个 .py
文件视为一个模块进行加载。但是模块的本质是 Cpython 组织可运行的指令的一种形式,所涉的主要概念就是 namespace
命名空间。Cpython 使用 包
.模块
.对象
.方法
这样的点分命名法标记命名空间;类似字典概念,点分字符串被作为查询符号定义的索引键值,避免符号命名的冲突的同时,在运行时快速 "寻址"。
包被视为特殊的模块,相比普通模块缺少部分魔法属性而已,同样可以包含一段自有代码。Cpython 将包含 __init__.py
文件的目录视作包的代码片段,目录下的其它 .py
文件视为模块。和模块一样,当包块被加载的时候,__init__.py
文件将首先被执行,可以在这个文件内运行程序块、定义类、函数、和对象。
此外还有所谓的命名空间包
,本质上和普通包没有区别,其特性不在此处讨论的范围内。
导入指令
Python 可以通过以下几类指令完成包导入:
python
import sys [as `some-other-name`] # 导入标准库模块 sys [并重命名为`some-other-name`] 其中 [ ... ] 内容可选
from sys import path # 从标准库 sys 中导入 path 变量/函数
import http.server # 从标准库 http 包中导入模块 server
from http.server import HTTPServer # 从标准库 http 包内的 server 模块导入 HTTPServer 类/方法/对象/符号
from . import foo # 从当前模块同级包导入 foo 模块
from .foo import bar # 从当前模块同级包内的 foo 模块导入 bar 符号
from ..foo import bar # 从当前模块上级包内的 foo 模块导入 bar 符号
注:相对导入没有 import .xxx 的写法
刨去语言细节,导入指令无非两类:导入模块(或包)vs 从指定模块(或包)中导入。由于包也是模块,分类简化为:
- 导入模块 - import <module>
- 从指定模块导入 - from <module> import <symbol> 搜寻包的方式无非也是两种:
- 绝对导入,从系统路径列表出发,查找对应的包进行导入 - import
'full module name'
- 相对导入,从当前模块出发,按照相对于当前模块的目录层次结构查找模块 - import
'dot starting module name'
python 模块导入方式,其实就这两个维度的分类组合而已。
搜索路径
Cpython 解释器在查找包时,默认的查找路径列表存储在 sys
模块的 path
列表对象中。保存以下代码到文件 '/path/to/your/project/src/test/syspath.py' , 然后在 '/path/to/your/project' 目录下执行 python ./src/test/syspath.py
,将会得到如下结果。
python
from sys import path
print(path)
# ['/path/to/your/project/src/test', '/usr/lib/python311.zip', '/usr/lib/python3.11', '/usr/lib/python3.11/lib-dynload', '/home/$(whoami)/.local/lib/python3.11/site-packages', '/usr/local/lib/python3.11/dist-packages', '/usr/lib/python3/dist-packages']
如试验结果,列表中的第一项是当前被运行的模块所在的目录,与运行程序时的 pwd
路径无关。其后的路径均为系统存储 python 公共库的目录。通过 pip
安装的库都存储在第二条以后的路径中,并且只能通过绝对引用的方式导入本地,所以通常不会存在问题。而正是由于第一条记录的原因,使相对引用变得难以预测。
相对引用的麻烦
相对引用主要是指在 import 指令中给出的模块命名。以 .
或 ..
等开头;正如语句后的注释给出的路径,一个点代表当前模块所在目录,二个或者更多点代表父级以上的目录。
python
from .foo import bar # load bar from module ./foo.py
from ..any.foo import bar # load bar from module ../any/foo.py
from ...any.foo import bar # load bar from module ../../any/foo.py
约束一:入口模块
相对导入的形式并不难理解。但是,相对引用存在一些约束,如果不了解,就容易产生莫名其妙的错误。以条件一为例,下面的代码将产生这样的输出:
python
print(f"Programe entry point: file: {__file__}; name: {__name__}; package: {__package__}")
from . import foo
# Programe entry point: file: /path/to/your/project/./src/test/basic.py; name: __main__; package: None
# ImportError: attempted relative import with no known parent package
程序运行时抛出异常 - "ImportError:在没有已知的父级包情况下尝试相对导入",实际上从的魔法属性中已可以看出,文件名为 basic.py 的模块,其名字被改为 _main _ ,而 _package _ 属性则被设置为 None
。这是影响相对导入的第一条约束:
(1) Cpython 解释器认为程序的入口模块不属于任何包,当然也就不能从它执行相对导入
约束二:顶层包限制
如果在下面的目录结构中执行 python ./src/test/basic.py
就可能遇到顶层包约束
的陷阱。
./src/test/: init.py basic.py ./src/test/foo: bar.py init.py ./src/test/pack_1: init.py mod_1.py
以 basic.py 作为入口,引用同级的包 foo 下的 bar 模块;bar 模块中反向引用上级的 pack_1 包内的 mod_1.py 模块。
python
# basic.py
print(f"Programe entry point: file: {__file__}; name: {__name__}; package: {__package__}")
from foo import bar
# ./foo/bar.py
print(f"Programe entry point: file: {__file__}; name: {__name__}; package: {__package__}")
from ..pack_1 import mod_1
# ./pack_1/mod_1.py
print(f"Programe entry point: file: {__file__}; name: {__name__}; package: {__package__}")
# Programe entry point: file: /path/to/your/project/./src/test/basic.py; name: __main__; package: None
# Programe entry point: file: /path/to/your/project/src/test/foo/__init__.py; name: foo; package: foo
# Programe entry point: file: /path/to/your/project/src/test/foo/bar.py; name: foo.bar; package: foo
# Traceback (most recent call last):
# from ..pack_1 import mod_1
# ImportError: attempted relative import beyond top-level package
结果显示运行时异常 - ImportError: 尝试相对导入顶层包以外的(模块)
原因是:basic.py 作为程序入口,本地搜索路径被指定为其所在的目录,此处为 /path/to/your/project/./src/test/,由于这个路径两个子目录下均存在 __init__.py
, .../foo/ 和 .../pack_1/ 被视作两个独立的包 。虽然 ./src/test/ 目录下同样存在 __init__.py
, 但是由于搜索路径的原因,'test/' 本身并没有被视作一个包,从而造成两个包的根并没有合一。 从 bar.py 文件的输出可以看出,它的包被指定为:foo 而不是 test.foo,它本身的名字也被指定为 foo.bar,即说明其顶层包是 foo。所以访问 pack_1 的时候自然越过了顶层包。解决这个问题也很简单,因为 pack_1 包在可以查找的路径上,仅需要将 from ..pack_1 import
改为绝对导入 from pack_1 import
即可。
(2) Cpython 解释器不允许一个模块执行相对导入时回溯的层次超过本模块的根
模块名称
约束三比较容易被忽视,在纯粹由 python 脚本构成的应用中,每个模块都和 .py
文件名称一一对应,自然不会是一个问题。
但是当应用集成了 Cython,或需要导入经过编译的 .pyd
或 .so
文件时,问题就变得棘手。为了便于发布,常常把多个模块编译成一个 .so
(运行时库)文件,问题就产生了。
假设路径 .../app/impl.so 路径上存在文件,由 foo 和 bar 两个模块编译而成,可能遇到两个中情况:
- 使用指令
import app.impl
加载时,Cpython 需要PyInit_impl()
方法,而这个方法通常不存在 - 使用指令
import app.foo
加载时,.../app/ 路径下根本不存在 foo.py 或 foo.so,Cpython 报告模块不存在。
(3) Cpython 加载模块时,首先需要同名文件存在;且如果该文件是运行时库,则必须包含 PyInit_'ModuleName'()方法,用以完成模块加载(的第一阶段)
多模块共享库
多模块共享库是把多个 Python/Cython 模块添加到同一个库文件中,由于前文所述的各种问题存在,多模块共享库的加载是一个相对麻烦的问题。尤其是 Cython ,其代码需要被
StackOverflow 上对这个问题有一个讨论,给出了两种可行的方案
方法一: 将多个 Cython 模块文件拷贝为同一个文件,即集中为同一个模块;
方法二: 自定义 CustomFinder
, 添加到 sys.metapath
为方便起见,采用 Python 3.5 以前的传统模块加载方法。当库所在的父包被导入时,通过__init__.py
模块调起模块中的函数,完成对 sys.metapath
的注册。以下时一个示例模板。
vbnet
Folder structure:
lua
../
|-- setup.py
|-- foo/
|-- __init__.py
|-- bar_a.pyx
|-- bar_b.pyx
|-- bootstrap.pyx
init.py:
python
# bootstrap is the only module which
# can be loaded with default Python-machinery
# because the resulting extension is called `bootstrap`:
from . import bootstrap
# injecting our finders into sys.meta_path
# after that all other submodules can be loaded
bootstrap.bootstrap_cython_submodules()
bootstrap.pyx:
cython
import sys
import importlib
# custom loader is just a wrapper around the right init-function
class CythonPackageLoader(importlib.abc.Loader):
def __init__(self, init_function):
super(CythonPackageLoader, self).__init__()
self.init_module = init_function
def load_module(self, fullname):
if fullname not in sys.modules:
sys.modules[fullname] = self.init_module()
return sys.modules[fullname]
# custom finder just maps the module name to init-function
class CythonPackageMetaPathFinder(importlib.abc.MetaPathFinder):
def __init__(self, init_dict):
super(CythonPackageMetaPathFinder, self).__init__()
self.init_dict=init_dict
def find_module(self, fullname, path):
try:
return CythonPackageLoader(self.init_dict[fullname])
except KeyError:
return None
# making init-function from other modules accessible:
cdef extern from *:
"""
PyObject *PyInit_bar_a(void);
PyObject *PyInit_bar_b(void);
"""
object PyInit_bar_a()
object PyInit_bar_b()
# injecting custom finder/loaders into sys.meta_path:
def bootstrap_cython_submodules():
init_dict={"foo.bar_a" : init_module_bar_a,
"foo.bar_b" : init_module_bar_b}
sys.meta_path.append(CythonPackageMetaPathFinder(init_dict))
Python Docs 的说明:
sys.metapath
是一个MetaPathFinder
对象的列表,当需要加载模块时,find_spec()
方法被调用,该方法返回模块的module spec
。当被导入的模块包含在一个包内时,父包的路径作为第二个参数传入find_spec()
方法。
因此这个方法还有一个 find_spec() 的版本,这个方案使用标准的加载方式,但是把 .so
文件名作为路径传入。由解释器根据包名称去推导正确的 PyInit_***()
方法。这种方式因为不再自行加载模块,所以可以和多阶段加载相互兼容。
python
import sys
import importlib
import importlib.abc
# Chooses the right init function
class CythonPackageMetaPathFinder(importlib.abc.MetaPathFinder):
def __init__(self, name_filter):
super(CythonPackageMetaPathFinder, self).__init__()
self.name_filter = name_filter
def find_spec(self, fullname, path, target=None):
if fullname.startswith(self.name_filter):
# use this extension-file but PyInit-function of another module:
loader = importlib.machinery.ExtensionFileLoader(fullname, __file__)
return importlib.util.spec_from_loader(fullname, loader)
# injecting custom finder/loaders into sys.meta_path:
def bootstrap_cython_submodules():
sys.meta_path.append(CythonPackageMetaPathFinder('foo.'))
从 C 程序中加载模块
此外,在 Cython Docs 官方文档也给出了从 C 程序中加载 Python 模块的方案:使用 C API PyImport_AppendInittab
, 使该模块成为内建模块,从而绕开路径搜索。
cython
cdef extern from "Python.h":
int PyImport_AppendInittab(const char *name, object (*initfunc)())
cdef extern from *:
PyObject *PyInit_target-module-name(void);
PyImport_AppendInittab("target-module-name", PyInit_target-module-name)
...
Py_Initialize()
从 .py
脚本加载模块的规避方案
受 Multiple modules in one library 启发,可以在目录下添加 __init__.py
文件,在其中加载模块和。而应用代码中直接 Import 整包。
init.py
python
import importlib.machinery
import importlib.util
loader = importlib.machinery.ExtensionFileLoader(name, path)
spec = importlib.util.spec_from_loader(name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
# return module
python
import foo # This will import all modules package foo
参考资料
**Stack Overflow - Calling Cython function from C code raises segmentation fault
**Python-Module: Collapse multiple submodules to one Cython extension
**Python-Doc: importlib - import 的实现
**Cython-Doc: Cython - Source Files and Compilation: Integrating multiple modules