likes
comments
collection
share

Python:说明白 Import 这件事

作者站长头像
站长
· 阅读数 2

摘要

在 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)。

❯ python --version
Python 3.11.6

同时,本文多模块共享库的相关内容,假设读者对 Python 代码编译或 Cython 开发有一定了解。

模块、包和命名空间

Python 的模块是代码的基本单元,CPython 解释器将一个 .py 文件视为一个模块进行加载。但是模块的本质是 CPython 组织可运行的指令的一种形式,所涉的主要概念就是 namespace 命名空间。CPython 使用 .模块.对象.方法 这样的点分命名法标记命名空间;类似字典概念,点分字符串被作为查询符号定义的索引键值,避免符号命名的冲突的同时,在运行时快速 "寻址"。

包被视为特殊的模块,导入机制将拥有 __path__ 魔法属性的模块视作为包。包同样可以包含一段代码:所在目录的 __init__.py 文件内容则被视为包属代码。和模块一样,当包块被加载的时候,__init__.py 文件将首先被执行,可以在这个文件内运行程序块、定义类、函数、和对象。

此外还有所谓的命名空间包,本质上和普通包没有区别,其特性不在此处讨论的范围内,具体可参考 #PEP420 。

导入指令

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 'full module name'
  • 相对导入,从当前模块出发,按照相对于当前模块的目录层次结构查找模块 -- import 'dot starting module name'

搜索路径

Cpython 解释器在查找包时,默认的查找路径列表存储在 sys 模块的 path 列表对象中。保存以下代码到文件 '/path/to/your/project/src/test/syspath.py' , 然后在 '/path/to/your/project' 目录下执行 python ./src/test/syspath.py ,将会得到如下结果。

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 指令中的模块命名以 ... 等开头;正如语句后的注释给出的路径,一个点代表当前模块所在目录,二个或者更多点代表父级以上的目录。

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

相对导入的书写格式并不难理解。但是相对引用存在一些约束,如果不了解就容易产生莫名其妙的错误。

约束一:__main__ 模块

以条件一为例,下面的代码将产生这样的输出:

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 解释器认为程序的 __main__ 模块不属于任何包,当然也就不能从它执行相对导入。

约束二:顶层包限制

如果在下面的目录结构中执行 python ./src/test/basic.py 就可能遇到顶层包约束 陷阱。

.
└── src
    ├── __init__.py
    ├── basic.py
    └── foo
    │   ├── __init__.py
    │   └── bar.py
    └── pack_1 
        ├── cerr.c
        └── __init__.py

以 basic.py 作为入口,引用同级的包 foo 下的 bar 模块;bar 模块中反向引用上级的 pack_1 包内的 mod_1.py 模块。


# 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 作为 __main__ 模块,本地搜索路径被指定为其所在的目录。此路径下两个子目录下均存在 __init__.py,因此 foo/ 和 pack_1/ 被视作两个 package 。虽然应用根目录下同样存在 __init__.py, 但是由于搜索路径的原因,本身并没有被视作一个包,从而造成两个子包的根最终没有合并。

从 bar.py 文件的输出可以看出,它的包被指定为:foo 而不是 test.foo,它本身的名字也被指定为 foo.bar,即说明其顶层包是 foo;所以访问 pack_1 的时候自然越过了顶层包。

解决这个问题也很简单,因为 pack_1 包在可以查找的路径上,仅需要将 from ..pack_1 import 改为绝对导入 from pack_1 import 即可。

这就是影响相对导入的第二个约束

(2) CPython 解释器不允许一个模块执行相对导入时回溯的层次超过本模块的根

约束三:模块文件名

约束三比较容易被忽视,并且在绝对导入中同样会遇到。

在纯 python 脚本构成的应用中,每个模块都和 .py 文件名称一一对应,自然不会是一个问题(实际上,CPython 解释器允许文件名以一定的格式存在,而不是必须完全一致)。但是当应用集成了 经过编译的 .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’() 方法,用以完成模块加载(指传统方式;如果是多阶段加载,那么仅完成第一阶段)

解决多模块共享库的导入

由于前文所述的各种问题存在,多模块共享库的加载相对麻烦。StackOverflow 上对这个问题有一个讨论,给出了两种可行的方案:

方法一: 将多个 Cython 模块文件拷贝到同一个文件中,即混作一个模块进行编译

这个方法本身比较简单,而且也不太优雅 ...

方法二: 自定义 CustomFinder, 添加到 sys.metapath

解决思路源自 #PEP302 导入协议。根据协议,导入子模块前 Importer 必须首先导入包本身。那么,可以按照第二部分 Specification part 2: Registering Hooks 描述的方法,在包的 __init__.py 文件中,创建适用于子模块的 finderloader 。由于包的导入早于模块的导入,此时完成对 sys.metapath 的注册,可用于子模块的加载过程。

为方便起见,以下代码未采用多阶段加载模式,Python 3.5 版本以前同样适用。创建以下文件:

Folder structure:

./
 |-- setup.py
 |-- foo/
      |-- __init__.py
      |-- bar_a.pyx
      |-- bar_b.pyx
      |-- bootstrap.pyx
      └── bootstrap.so            # built lib

__init__.py:

# 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:

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))

在 #PEP451 A ModuleSpec Type for the Import System 发布之后,finder 对象引入了 find_spec() 方法,该方法取代 find_module() 方法,并将loader 包装在 ModuleSpec 对象中返回。因此有:

使用 find_spec() 的版本

这个方案不改变 loader 的行为模式,模块的 full_name 被传入自定义的 finder.find_spec() 方法,通过把 .so 文件名称和模块名进行组合,使 loader 根据包名称推导出的 PyInit_***() 方法在库中可以被找到。

这种方式不再自行定制 loader,所以可以兼容多阶段加载和传统加载两种模式。

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.')) 

Python Docs:sys.metapath 是一个 MetaPathFinder 对象的列表,当需要加载模块时,find_spec() 方法被调用,该方法返回模块的 module spec。当被导入的模块包含在一个包内时,父包的路径作为第二个参数传入find_spec() 方法。

注: 代码原型源自于 #PEP489 Multiple modules in one library,是目前已知最优雅的方案,原型实现如下:

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

从 C 程序中加载模块

此前的方法适用于 Python 应用,如果需要在 C 应用中访问 Python 模块又该怎么办呢? 在 Cython Docs 官方文档也给出了从 C 程序中加载 Python 模块的方案:使用 C API PyImport_AppendInittab, 使该模块成为内建模块,从而绕开路径搜索。

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()

参考资料

**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

**Github: Cython - Cython_freeze