摘要

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 使用 .模块.目标.办法 这样的点分命名法符号命名空间;相似字典概念,点分字符串被作为查询符号定义的索引键值,防止符号命名的冲突的一起,在运转时快速 “寻址”。

包被视为特殊的模块,相比一般模块短少部分魔法特点罢了,相同能够包括一段自有代码。Cpython 将包括 __init__.py 文件的目录视作包的代码片段,目录下的其它 .py 文件视为模块。和模块相同,当包块被加载的时分,__init__.py 文件将首要被履行,能够在这个文件内运转程序块、定义类、函数、和目标。

此外还有所谓的命名空间包,本质上和一般包没有区别,其特性不在此处评论的范围内。

导入指令

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 ,将会得到如下成果。

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

束缚一:进口模块

相对导入的方法并不难理解。可是,相对引证存在一些束缚,假如不了解,就简单发生不可思议的过错。以条件一为例,下面的代码将发生这样的输出:

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 模块。


# 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 的注册。以下时一个示例模板。

Folder structure:
../
 |-- setup.py
 |-- foo/
      |-- __init__.py
      |-- bar_a.pyx
      |-- bar_b.pyx
      |-- bootstrap.pyx

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

Python Docs 的阐明:sys.metapath 是一个 MetaPathFinder 目标的列表,当需求加载模块时,find_spec() 办法被调用,该办法回来模块的 module spec。当被导入的模块包括在一个包内时,父包的途径作为第二个参数传入find_spec() 办法。

因此这个办法还有一个 find_spec() 的版别,这个计划使用规范的加载办法,可是把 .so 文件名作为途径传入。由解说器依据包称号去推导正确的 PyInit_***() 办法。这种办法因为不再自行加载模块,所以能够和多阶段加载相互兼容。

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, 使该模块成为内建模块,然后绕开途径查找。

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

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

app.py

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

**Github: Cython – Cython_freeze