Python 源码解读 之 模块导入系统

原创作品,参考请注明链接。

前言:为什么跟我想的不一样?

Python 的模块导入一般使用 import 语句,它简单易用,但有时也会产生一些难以理解的报错信息,比如:

  • 项目文件在 PyCharm 里面能正常运行,但在命令行里运行却报错:ModuleNotFoundError: No module named ‘xxx’
  • 一些标准库里或第三方的文件能够被正常导入(这个过程会执行其代码),但直接运行这个文件却报错:ImportError: attempted relative import with no known parent package
  • 在某文件内写了相对导入语句,PyCharm 也未报错,但运行直接或间接导入该文件的其他文件时,却正是那句相对导入语句报错:ImportError:attempted relative import beyond top-level package
  • 一个模块使用 python -m mod.full.name 的形式能正常运行,但使用 python /path/to/script.py 的方式却报错:ImportError: attempted relative import with no known parent package

如果读者想快速找到这些问题的答案,可直接移步‘现象解释’章节。若仍未彻底理解,或希望获得对 Python 模块导入系统全貌的理解,则可通读全文。

相关源码

Python 版本:CPython 3.11.5。
模块导入相关源码:

说明:为了方便理解主线逻辑,文中多处源码有删改,不再特别指出。

模块是什么?

模块(module)的本质就是一个含有 Python 代码(准确地说,是 Python 解释器能理解的代码)的文件。一个模块可以通过导入操作来访问外一个模块内的代码。模块导入机制为我们提供了依赖解决方案,也使我们能将一个大的逻辑分散于不同的文件,方便项目组织。

types.ModuleType

模块在被导入后体现为一个模块对象。虽然 Python 有多种类型的模块(详见下一节),但只有一个统一的内置模块类,即 types.ModuleType。所有模块对象都是 ModuleType 这个类的实例。

模块对象的重要属性有:

  • __name__ : 模块的完整名称,可能包含点号。
  • __package__:模块所属包的完整名称。
  • __file__:模块的定义文件在文件系统中的绝对路径。
  • __path__:包模块独有,定义子模块寻找路径的列表。
  • __spec__:模块的规格,是 importer/finder 在找到模块时创建的 ModuleSpec 对象。

不同类型的模块可以通过模块对象的属性来区分,也会由不同的 importer/finder 类或实例来负责寻找。

sys.modules

系统变量sys.modules是一个字典,缓存已导入的模块对象,key为模块全名。

$ python
Python 3.11.5 (main, Aug 25 2023, 13:19:53) [GCC 9.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> sys.modules
{'sys': <module 'sys' (built-in)>, 'builtins': <module 'builtins' (built-in)>, '_frozen_importlib': <module '_frozen_importlib' (frozen)>, ...}

模块有几种?

Python 模块的分类及优先级:

  • 入口模块:任意一个 Python 程序(包括交互式会话)运行时自动创建的一个模块,其 __package__ 为 None,__spec__ 为 None,__name__ 为 ‘__main__’。
  • 存在于 sys.modules缓存中的模块(可以是以下任意一种模块,置于此处只为表明优先级)。
  • built-in modules:用其他语言(通常是与编写解释器相同的语言,如对 CPython 来说就是 C )编写并嵌入解释器的模块,如 sys, time 等,由 BuiltinImporter类负责寻找。
  • frozen modules:用 Python 编写,编译成字节码然后嵌入解释器的模块,如 os, os.path, __hello__, _collections_abc 等,由FrozenImporter类负责寻找。
  • 来自文件系统的模块,由PathFinder类负责寻找(实际会尝试创建下面两种 importer/finder 实例,然后将寻找工作交给它们)。
    • 存放于 zip 压缩档内部的模块,由 zipimporter实例负责寻找。
    • 存放于目录内的模块,由 FileFinder实例负责寻找。
      • 常规包:对应一个含有 __init__.py 文件的目录。
      • 普通文件模块。
      • 命名空间包:对应一个或多个没有 __init__.py 文件的同名目录。

本文将重点关注我们平时最常用的常规包和普通文件模块,它们由 PathFinder类 及 FileFinder实例 负责寻找。

这里需要说明几点:

  • 包是可以拥有子模块的特殊模块,即所有包都是模块,但反过来不成立。
  • 所有的目录都可以成为一个包,无论它是否包含一个 __init__.py 文件。
    • 若目录内有 __init__.py,则它可以成为一个常规包, __init__.py 则是这个包模块的定义文件;
    • 若目录内没有 __init__.py,则它可以成为一个命名空间包的一个 portion。
  • 上面的模块分类排序也同时说明了在重名的情况下,各种模块类型的优先级,比如若在同一目录下 a/__init__.py 和 a.py 同时存在,则导入包模块。

finders

importer 与 finder 是相同的概念,即用来寻找模块的类,下文统称 finder。

finder 的主要作用就是通过其 find_spec 函数,根据模块名及路径寻找模块,若找到则返回一个 ModuleSpec 对象,找不到则返回 None。

finder 也分为两种:

  • 不需要实例化的 finder,其 find_spec 是类函数:
    • BuiltinImporter
    • FrozenImporter
    • PathFinder
  • 需要实例化的 finder,仅为上面的 PathFinder 所用,其 find_spec 是成员方法,实例由路径钩子生成且拥有 path 属性:
    • zipimporter
    • FileFinder

需要注意的是,所有这些 finder 并没有一个共同的父类。

与 finder 相关的系统变量有 sys.meta_pathsys.path_hookssys.path_import_cachesys.path

sys.meta_path

sys.meta_path是一个列表,它的成员正是前面提到的第一种不需要实例化的 finder类。

>>> import sys
>>> sys.meta_path
[<class '_frozen_importlib.BuiltinImporter'>, <class '_frozen_importlib.FrozenImporter'>, <class '_frozen_importlib_external.PathFinder'>]

sys.path_hooks

sys.path_hooks也是一个列表,与前面提到的第二种 finder 相关,但其成员并不一定直接是 finder类,而是可调用对象的路径钩子,这些路径钩子接收一个 path 参数,若 path 能够对应相应的 finder,则用 path 构造并返回一个相应的 finder 实例,否则抛出 ImportError。

>>> sys.path_hooks
[<class 'zipimport.zipimporter'>, <function FileFinder.path_hook.<locals>.path_hook_for_FileFinder at 0x7f8bdab382c0>]

这两个系统变量为什么会有这些内容呢?我们可以在 Python 的源码中找到根据。
import.c , importlib/_bootstrap.py 和 importlib/_bootstrap_external.py 中以下函数在 Python 解释器启动的过程中就会执行:

// import.c

PyStatus
_PyImportZip_Init(PyThreadState *tstate)
{

    PyObject *path_hooks = PySys_GetObject("path_hooks");
    PyObject *zipimport = PyImport_ImportModule("zipimport");
    PyObject *zipimporter = PyObject_GetAttr(zipimport, &_Py_ID(zipimporter));
    PyList_Insert(path_hooks, 0, zipimporter)
    /* 所有这些语句相当于在Python中执行了:
       import zipimport
       sys.path_hooks.insert(0, zipimport.zipimporter) */

    return _PyStatus_OK();
}
# importlib/_bootstrap.py

def _install(sys_module, _imp_module):
    """Install importers for builtin and frozen modules"""
    _setup(sys_module, _imp_module)

    sys.meta_path.append(BuiltinImporter)
    sys.meta_path.append(FrozenImporter)
# importlib/_bootstrap_external.py

def _install(_bootstrap_module):
    """Install the path-based import components."""
    _set_bootstrap_module(_bootstrap_module)
    supported_loaders = _get_supported_file_loaders()
    sys.path_hooks.extend([FileFinder.path_hook(*supported_loaders)])
    sys.meta_path.append(PathFinder)

模块文件的后缀

我们再追踪一下第二个路径钩子,即 path_hook_for_FileFinder 是如何构造一个 FilerFinder 实例的:

# importlib/_bootstrap_external.py

def _get_supported_file_loaders():
    """Returns a list of file-based module loaders.

    Each item is a tuple (loader, suffixes).
    """
    extensions = ExtensionFileLoader, _imp.extension_suffixes()
    source = SourceFileLoader, SOURCE_SUFFIXES
    bytecode = SourcelessFileLoader, BYTECODE_SUFFIXES
    return [extensions, source, bytecode]

class FileFinder:
    def __init__(self, path, *loader_details):
        """Initialize with the path to search on and a variable number of
        2-tuples containing the loader and the file suffixes the loader
        recognizes."""
        # 设置实例的 _loaders 属性为(suffix, loader)元组组成的列表
        loaders = []
        for loader, suffixes in loader_details:
            loaders.extend((suffix, loader) for suffix in suffixes)
        self._loaders = loaders
        # Base (directory) path
        # 设置为实例的 path 属性为 path 参数转换成的绝对路径
        if not path or path == '.':
            self.path = _os.getcwd()
        elif not _path_isabs(path):
            self.path = _path_join(_os.getcwd(), path)
        else:
            self.path = path
        # _path_mtimen属性记录目录的修改时间
        self._path_mtime = -1
        # _path_cache 属性缓存目录的内容
        self._path_cache = set()

    
    @classmethod
    def path_hook(cls, *loader_details):
    
        def path_hook_for_FileFinder(path):
            if not _path_isdir(path):
                raise ImportError('only directories are supported', path=path)
            return cls(path, *loader_details)

        return path_hook_for_FileFinder

我们会发现通过 path_hook_for_FileFinder 构造的 FilerFinder 实例的 _loaders 属性是是解释器启动时就定义好的,而通过这个属性我们可以知道 Python 模块的定义文件支持哪些后缀:

>>> hook = sys.path_hooks[-1]
>>> finder = hook(".")
>>> [l[0] for l in finder._loaders]
# 在 linux 下:
['.cpython-311-x86_64-linux-gnu.so', '.abi3.so', '.so', '.py', '.pyc']

# 在 Windows 下:
['.cp311-win_amd64.pyd', '.pyd', '.py', '.pyw', '.pyc']

sys.path_importer_cache

系统变量sys.path_importer_cache也与前面提到的第二种 finder 相关,它是一个字典,缓存路径对应的 finder 实例。

>>> import sys
>>> sys.path_importer_cache
{'/usr/lib/python311.zip': None, '/usr/lib/python3.11': FileFinder('/usr/lib/python3.11'), '/usr/lib/python3.11/encodings': FileFinder('/usr/lib/python3.11/encodings'), ...}

sys.path

系统变量sys.path是一个列表,它定义了来自文件系统的顶层模块(模块全名不含点号)的寻找路径,这些路径既可以指向一个目录,也可以指定一个zip压缩文档。

sys.path默认包含 Python 的安装目录及环境变量 PYTHONPATH 中的每一个目录。

Python 解释器运行入口文件之前,会将文件所在目录添加为sys.path第一项。交互式会话属此类的特殊情况,此时“文件所在目录”为空字符串"",表示可变的工作目录。

PyCharm 运行入口文件之前,会将文件所在目录和项目根目录添加为sys.path前两项;
PyCharm 运行控制台之前,会将项目根目录添加为sys.path最后一项;
PyCharm“Run File in Python Console”还会将文件所在目录添加为sys.path第一项,将项目根目录添加为sys.path最后一项。

_bootstrap.ModuleSpec

前面提到各种 finder 的主要作用就是通过其 find_spec 函数,尝试返回一个 ModuleSpec 对象,现在我们来了解一下这个类。

# importlib/_bootstrap.py

class ModuleSpec:
    def __init__(self, name, loader, *, origin=None, is_package=None):
        self.name = name
        self.loader = loader
        self.origin = origin
        self.submodule_search_locations = [] if is_package else None
        self._set_fileattr = False

    @property
    def parent(self):
        """The name of the module's parent."""
        if self.submodule_search_locations is None:
            return self.name.rpartition('.')[0]
        else:
            return self.name
    
    @property
    def has_location(self):
        return self._set_fileattr

ModuleSpec 对象可以认为是模块导入过程中产生的中间对象,即找到模块位置后,用来记录模块的一些基本信息的对象,一般先于模块对象的产生。

ModuleSpec 对象的重要属性有:

  • submodule_search_locations: 包模块独有,提供其子模块的搜索路径,对应模块对象的__path__属性。
  • name: 模块的完整名称,对应模块对象的__name__属性。
  • parent: 模块所属包的完整名称,对应模块对象的__package__属性,由 name 产生;对于包模块来说,其所属包就是它自己。
  • loader: 模块的加载器。
  • origin: 模块的来源,其值可为"built-in",“frozen”,或者模块文件的路径(此时对应模块对象的__file__属性)。

模块的导入意味着什么?

模块的导入实际分为两个步骤:寻找+加载。

模块寻找的过程,就是根据模块的完整名称,尝试用各种 finder 找到模块的位置,然后返回一个 ModuleSpec 对象。这个过程正好体现了前面提到的模块类型优先级。

而模块加载的过程,就是前面返回的 ModuleSpec 对象创建并初始化一个模块对象(即 ModuleType 实例),然后将其返回。这个过程会执行模块文件中的代码。

寻找

源码中寻找与加载的过程是写在一起的(_find_and_load),我们先只关注寻找逻辑:

# importlib/_bootstrap.py

_NEEDS_LOADING = object()
def _find_and_load(abs_name, import_):
    # 缓存最优先
    module = sys.modules.get(abs_name, _NEEDS_LOADING)
    # 在缓存中将 abs_name 对应的值设置为 None,可以阻止导入以 abs_name 为名的模块
    if module is None:
        message = (f'import of {abs_name} halted; None in sys.modules')
        raise ModuleNotFoundError(message, name=abs_name)
    # 若缓存中已有该名字的模块,则不需要再次寻找、加载
    if module is not _NEEDS_LOADING:
        return module

    # 需要寻找新的模块
    return _find_and_load_unlocked(abs_name, import_)

_find_and_load 函数已经体现了 sys.modules缓存最优先,也解释了为什么多次导入同一名称的模块,模块的代码却只会执行一次:从第二次导入开始,模块在缓存中找到,就不会进入加载的步骤去执行模块代码。

我们接着看没有缓存时继续调用 _find_and_load_unlocked:

# importlib/_bootstrap.py

def _find_and_load_unlocked(abs_name, import_):
    path = None
    parent, _, tail = abs_name.rpartition('.')
    # 如果模块名带点号,则先导入其父模块,然后在父模块的__path__属性中定义的路径中去寻找子模块
    if parent:
        if parent not in sys.modules:
            import_(parent)
        # The parent import may have already imported this module!
        if abs_name in sys.modules:
            return sys.modules[abs_name]
        parent_module = sys.modules[parent]
        try:
            path = parent_module.__path__
        except AttributeError:
            msg = f'No module named {abs_name!r}; {parent!r} is not a package'
            raise ModuleNotFoundError(msg, name=abs_name) from None

    spec = _find_spec(abs_name, path)
    if spec is None:
        raise ModuleNotFoundError(f'No module named {abs_name!r}', name=abs_name)

	# 若找到非 None 的 spec,开始加载模块,此处暂省
	...

这个函数的寻找逻辑部分主要是处理了父模块的情况,然后将具体的寻找工作进一步交给 _find_spec 函数。

注意:这里的 parent 是“真父”,纯根据模块完整名称来推断,比如,无论"a.b.c"是不是包,在这里它的 parent 都是 “a.b”;而在前面提到的 ModuleSpec 里,当 “a.b.c” 是包时,其 parent 为 “a.b.c”,否则才为 “a.b”。
为避免混淆,我们将与模块类型相关的 ModuleSpec.parent 以及 ModuleType.__package__ 称为 “所属包”,而只将在这里仅与模块名称相关的 parent 称为“父模块”或“父包”。

如果一个完整的模块名称不带点号,那它就是一个顶层模块,传入的 _find_spec 的 path 为 None,否则为其父模块的 __path__ 属性。

从这里也可以看出,当我们导入名为 “a.b.c” 的模块时,严格来讲是递归的逻辑,而不是先导入"a", 再从"a"里导入"b",最后从"a.b"里导入"c"的循环逻辑。

继续看 _find_spec 函数:

# importlib/_bootstrap.py 

def _find_spec(abs_name, path):
    # sys.meta_path = [BuiltinImporter, FrozenImporter, PathFinder]
    for finder in sys.meta_path:
        spec = finder.find_spec(abs_name, path)
        if spec is not None:
            return spec
    else:
        return None

有序迭代列表sys.meta_path,就体现了 BuiltinImporter,FrozenImporter,PathFinder 的优先级。举例而言,即使你有一个名为 os.py 的文件,你也不能把它导入为全名为"os"的模块,这里因为当你尝试以"os"为全名导入模块时,由 FrozenImporter 找到了解释器内的非 None 的 spec,_find_spec 直接返回,轮不到 PathFinder 去寻找文件系统内的模块。

BuiltinImporter.find_spec 与 FrozenImporter.find_spec 都是在解释器内部寻找模块,一般不用我们用户操心。接下来我们重点关注 PathFinder.find_spec 是如何在解释器外部,即文件系统内寻找模块的。

PathFinder

# importlib/_bootstrap_external.py

class PathFinder:
    @classmethod
    def find_spec(cls, fullname, path=None):
        """Try to find a spec for 'fullname' on sys.path or 'path'.

        The search is based on sys.path_hooks and sys.path_importer_cache.
        """
        if path is None:
            path = sys.path
        spec = cls._get_spec(fullname, path)

        if spec.loader is not None:
            return spec
        else:
            potential_namespace_path = spec.submodule_search_locations
            if potential_namespace_path:
                # We found at least one namespace path.  Return a spec which
                # can create the namespace package.
                spec.submodule_search_locations = _NamespacePath(fullname, namespace_path, cls._get_spec)
                return spec
            else:
                return None

    @classmethod
    def _get_spec(cls, fullname, path):
    """Find the loader or namespace_path for this module/package name."""
    # If this ends up being a namespace package, namespace_path is
    #  the list of paths that will become its __path__
    potential_namespace_path = []
    for entry in path:
        if not isinstance(entry, str):
            continue
        finder = cls._path_importer_cache(entry)
        if finder is not None:
            spec = finder.find_spec(fullname)
            if spec is None:
                continue
            if spec.loader is not None:
                return spec
            portions = spec.submodule_search_locations
            if portions is None:
                raise ImportError('spec missing loader')
            # This is possibly part of a namespace package.
            #  Remember these path entries (if any) for when we
            #  create a namespace package, and continue iterating
            #  on path.
            potential_namespace_path.extend(portions)

    spec = _bootstrap.ModuleSpec(fullname, None)
    spec.submodule_search_locations = potential_namespace_path
    return spec

    @classmethod
    def _path_importer_cache(cls, path):
        if path == '':
            path = os.getcwd()
        try:
            finder = sys.path_importer_cache[path]
        except KeyError:
            finder = cls._path_hooks(path)
            sys.path_importer_cache[path] = finder
        return finder

	@staticmethod
    def _path_hooks(path):
        # sys.path_hooks = [zipimport.zipimporter, path_hook_for_FileFinder]
        for hook in sys.path_hooks:
            try:
                return hook(path)
            except ImportError:
                continue
        return None

首先,在函数 PathFinder.find_spec 中,值为 None 的 path 参数被转换为指向系统变量 sys.path,印证了:来自文件系统模块,若是顶层模块,其寻找路径由 sys.path 定义,否则由其父模块的 __path__ 属性定义。

这里涉及到命名空间包的处理逻辑,先举一个例子,以便我们对命名空间包有一个感性认识:
假设 sys.path = [‘/path1’, ‘/path2’],而 /path1 和 /path2 下均有一个名为 "a"的目录,其下均可以有其他文件但不能有__init__.py,那么 import a 就会得到一个命名空间包,其__path__属性包含 ‘/path1/a’ 和 ‘/path/b’ 两项。

继续看 PathFinder._get_spec 及其依赖函数。要注意 PathFinder.find_spec 和 PathFinder._get_spec 的 形式参数 path 是路径的列表,但 PathFinder._path_importer_cache 和 PathFinder._path_hooks ,以及 FileFinder.__init__ 的形式参数 path 是单个路径(entry)。

对 path 中的每个路径 entry 来说:

  • 首先,创建一个合适的 finder实例来进行后续的寻找工作。PathFinder._path_hooks 表明,一个用来寻找模块的路径,首先是尝试将其当成一个zip压缩文档,然后才尝试将其当成一个目录。若两次尝试都失败,则说明这个路径非法,不可能在这个路径下寻找模块,继续处理下一个路径。
  • 如果某个路径钩子根据 entry 成功拿到一个非 None 的 finder 实例,则尝试用这个 finder ,根据模块尾名 (tail) 来寻找模块,返回一个ModuleSpec 对象 spec。
    • 若找不到(spec 为 None),则说明 entry 下没有相关名称的模块,继续处理下一个路径。
    • 若找到了,且 spec.loader 不为 None,说明找到了常规包({entry}/{tail}/__init__.py)或普通文件模块({entry}/{tail}.py)那么,就是它了!不用看其他路径了,PathFinder._get_spec 与 FileFinder.find_spec 相继返回 spec。
    • 若找到了,但 spec.loader 为 None,说明找到了 {entry}/{tail}/ 目录,但目录下没有 __init__.py 文件,那么这个目录可能是一个潜在的命名空间包的一部分。但由于还未处理的路径下仍可能存在更有用的常规包或普通文件模块,所以只是暂时把 {entry}/{tail}/ 记录进 potential_namespace_path 列表中,待循环结束再处理。

循环结束,而 _get_spec 仍未返回,说明所有 entry 下面都没有找到常规包模块或普通文件模块,则 _get_spec 构造一个潜在的命名空间包的 spec 返回给 PathFinder.find_spec(其submodule_search_locations 属性为所有 entry 下名为 tail 又不含 __init__.py 的目录),后者继续判断 spec.submodule_search_locations:

  • 若其不为空,则将该属性标记为_NamespacePath,然后返回 spec
  • 若其为空,说明在所有的 entry 下面,非但没有找到任何名为 tail 的常规包或普通文件模块,连命名空间包也没有找到,那么真是什么都没找到了,返回 None,最终在 _find_and_load_unlocked 函数里抛出 ModuleNotFoundError。

上面的文字描述中的一些细节来自 FileFinder.find_spec,补充如下。

FileFinder

# importlib/_bootstrap_external.py

class FileFinder:
    def _fill_cache(self):
        """Fill the cache of potential modules and packages for this directory."""
        path = self.path
        try:
            contents = _os.listdir(path or _os.getcwd())
        except (FileNotFoundError, PermissionError, NotADirectoryError):
            # Directory has either been removed, turned into a file, or made
            # unreadable.
            contents = []
        self._path_cache = set(contents)


    def find_spec(self, fullname):
        """Try to find a spec for the specified module.

        Returns the matching spec, or None if not found.
        """
        potential_namespace = False
        tail = fullname.rpartition('.')[2]

        # 如果目录被修改过了,则更新其内容缓存
        try:
            mtime = _path_stat(self.path or _os.getcwd()).st_mtime
        except OSError:
            mtime = -1
        if mtime != self._path_mtime:
            self._fill_cache()
            self._path_mtime = mtime

        contents_cache = self._path_cache

        # Check if the module is the name of a directory (and thus a package).
        if tail in contents_cache:
            base_path = _path_join(self.path, tail)
            for suffix, loader_class in self._loaders:
                init_filename = '__init__' + suffix
                full_path = _path_join(base_path, init_filename)
                if _path_isfile(full_path): # self.path 下存在 tail/__init__.py
                    return self._get_spec_from_file(loader_class, fullname, full_path, smsl=[base_path])
            else: # self.path 存在 tail/ 才可能是命名空间包的一部分
                potential_namespace = _path_isdir(base_path)

        # Check for a file w/ a proper suffix exists.
        for suffix, loader_class in self._loaders:
            try:
                full_path = _path_join(self.path, tail + suffix)
            except ValueError:
                return None
            if tail + suffix in contents_cache:
                if _path_isfile(full_path): # self.path 下存在 tail.py
                    return self._get_spec_from_file(loader_class, fullname, full_path)

        if potential_namespace:
            spec = _bootstrap.ModuleSpec(name=fullname, loader=None)
            spec.submodule_search_locations = [base_path]
            return spec

        return None

    def _get_spec_from_file(self, loader_class, fullname, full_path, smsl=None):
        loader = loader_class(fullname, full_path)
        spec = _bootstrap.ModuleSpec(name=fullname, loader=loader, origin=full_path)
        spec._set_fileattr = True
        spec.submodule_search_locations = smsl
        return spec

加载

加载的逻辑相对简单。让我们回到 _find_and_load_unlocked 函数,假设已经找到非 None 的 spec ,继续下去:

# importlib/_bootstrap.py

def _find_and_load_unlocked(abs_name, import_):
    ...
    # 前面已经通过 abs_name 找到非 None 的 spec
    module = _load_unlocked(spec)
    if parent:
        # 将模块以其尾名设置为其父模块的属性
        setattr(parent_module, tail, module)
    return module

def _load_unlocked(spec):
    # 创建模块对象,使用spec初始化其各项属性
    module = ModuleType(spec.name) # module.__name__ = spec.name
    module.__package__ = spec.parent
    if spec.submodule_search_locations is not None:
        module.__path__ = spec.submodule_search_locations
    if spec.has_location:
        module.__file__ = spec.origin
    
    # 将模块对象缓存到 sys.modules 中
    sys.modules[spec.name] = module

    # 执行模块定义文件的代码
    if spec.loader:
        spec.loader.exec_module(module)

    return module

class _LoaderBasics:
    def exec_module(self, module):
        """Execute the module."""
        code = self.get_code(module.__name__)
        # 在模块对象的__dict__中执行模块文件的代码,
        # 这样模块文件中定义的全局对象就成为模块对象的属性
        exec(code, globals=module.__dict__)

_find_and_load 函数已经给我们提供了完全可用的模块导入功能,但直接使用它还是不方便的,毕竟我们并不希望使用相应的模块对象时,还得去缓存sys.modules里去找。而这正是我们一般不会直接使用这个函数,而是使用 import 语句的原因。

import 语句做了什么工作?

import 语句是一种语法糖,除了导入(寻找+加载),还额外做了名称绑定的工作。

在导入模块时,我们常常有这样的需求:

  • “把叫这个名字的模块导入进来,同时把生成的模块对象绑定至当前命名空间”。
  • “把叫这个名字的模块导入进来,同时把模块中的一部分全局对象直接绑定至当前命名空间”。

而这两种需求刚好对应了 import 语句的两种格式:

  • import <name> :只能绝对导入,name 部分若有逗号则代表多条导入语句。
  • from <name> import <fromlist> :可以相对导入,fromlist 部分若有逗号仍只代表一条导入语句。

具体而言,每当 Python 程序遇到 import 语句,都等价于发生了如下转换:

# import a.b.c, a2.b2.c2 as d2 相当于:
_tmp_a = builtins.__import__(name="a.b.c", globals=globals(), fromlist=[], level=0)
a = _tmp_a # 使用"c"模块需要用 a.b.c 的形式

_tmp_a2 = builtins.__import__(name="a2.b2.c2", globals=globals(), fromlist=[], level=0)
d2 = _tmp_a2.b2.c2


# from a.b.c import d, e as f 相当于:
tmp_c = builtins.__import__(name="a.b.c", globals=globals(), fromlist=["d", "e"], level=0)
d = _tmp_c.d
f = _tmp_c.e


# from . import d ,相对导入语句,相当于:
_tmp = builtins.__import__(name="", globals=globals(), fromlist=["d"], level=1)
d = _tmp.a


# from ..a.b.c import d, e as f ,相对导入语句,相当于:
_tmp_c = builtins.__import__(name="a.b.c", globals=globals(), fromlist=["d", "e"], level=2)
d = _tmp_c.d
f = _tmp_c.e


# from a.b.c import * 相当于:
_tmp_c = builtins.__import__(name="a.b.c", globals=globals(), fromlist=["*"], level=0)
d = _tmp_c.d
e = _tmp_c.e
...

import 语句的工作流程如下:

  1. 根据 import 语句的格式和内容,分析出 name, fromlist 和 level 三个参数,这其中:
  • name 代表模块名称,相对导入时需要去除前置点且可为空字符串。
  • fromlist 代表需要从目标模块中导出的全局名称的列表,可能是模块内的全局对象,也可能是包的子模块。
  • level 代表相对导入的层级,等于 name 前置点的个数,level=0 表示绝对导入。
  1. 用这三个参数,外加代表当前模块全局命名空间的字典 globals(仅在相对导入时有用),去调用它的助手:builtins.__import__函数。builtins.__import__ 负责导入模块,然后返回特定的模块对象。
  2. 名称绑定。

可以看出,builtins.__import__ 并不执行名称绑定,它只是通过处理 fromlist 和选择特定的模块对象返回来配合这个工作。比如,当需要导入的模块名称含有点号时,实际会将其所有祖先包也一并导入,此时如果参数 fromlist 为空(准确地说,是其布尔值为 False),则 __import__ 返回顶层模块,否则返回底层模块。

import 语句之所以不直接调用 _find_and_load 函数,正是因为它不能处理相对导入的情况(只接受绝对完整模块名作为参数传入),亦不能配合名称绑定(只会永远返回底层模块)。

接下来我们进入builtins.__import__ 函数内部,看它是如何处理相对导入和 fromlist 的。

builtins.__import__

出于性能的考虑,builtins.__import__ 是用 C 语言实现,将其翻译为 Python 如下:

# import.c

import sys
from types import ModuleType
from importlib._bootstrap import _find_and_load, _handle_fromlist

NULL = object

def __import__(name, globals=NULL, locals=NULL, fromlist=None, level=0) -> ModuleType:
    # The following line is the biggest difference between builtins.__import__ 
    # and importlib.__import__ , where import_ = _gcd_import
    import_ = __import__

    if not isinstance(name, str):
        raise TypeError("module name must be a string")
    if level < 0:
        raise ValueError('level must be >= 0')

    if level > 0:
        abs_name = resolve_name(name, globals, level)
    else:  # level == 0
        if not name: # 绝对导入时名字不可为空
            raise ValueError('Empty module name')
        abs_name = name

    mod = _find_and_load(abs_name, import_)

    if not fromlist:
        # 返回顶层模块
        if level == 0:
            front, sep, _ = name.partition('.')
            # add this to avoid infinite recursion
            if not sep: # '.' not in name, front == name
                return mod
            return import_(front)

        # 下面的代码对于 import 语句来说似乎是无用的:
        # 如果 fromlist 为空,则一定是 'import <name>' 的绝对导入语句
        elif not name: # level > 0 and name == ""
            return mod
        else: # level > 0 and name != ""
            cut_off = len(name) - len(name.partition('.')[0])
            to_return = abs_name[:len(abs_name)-cut_off]
            try:
                return sys.modules[to_return]
            except KeyError:
                raise KeyError(f"{to_return!r} not in sys.modules as expected")
    # 返回底层模块
    elif hasattr(mod, '__path__'):
        # _handle_fromlist 最终会返回 mod
        return _handle_fromlist(mod, fromlist, import_)
    else:
        return mod

# resolve_name 相当于 importlib._bootstrap 中的 
# _calc___package__ + _sanity_check + _resolve_name
# 只有相对导入(level>0)时 才会调用此函数
def resolve_name(name, globals, level) -> str:
    """Resolve a relative module name to an absolute one."""
    if globals is NULL:
        raise KeyError("'__name__' not in globals")
    if not isinstance(globals, dict):
        raise TypeError("globals must be a dict")

    # importlib._bootstrap._calc___package__
    # 优先根据 __package__
    package = globals.get('__package__')
    spec = globals.get('__spec__')
    if package is not None:
        if not isinstance(package, str):
            raise TypeError('package must be a string')
    # 然后根据 __spec__.parent
    elif spec is not None:
        package = spec.parent
        if not isinstance(package, str):
            raise TypeError("__spec__.parent must be a string")
    # 最后根据 __name__
    else:
        package = globals.get('__name__', NULL)
        if package is NULL:
            raise KeyError("'__name__' not in globals")
        if not isinstance(package, str):
            raise TypeError("__name__ must be a string")
        if '__path__' not in globals: # 非包模块
            package = package.rpartition('.')[0]

    # importlib._bootstrap._sanity_check
    if not package:
        raise ImportError('attempted relative import with no known parent package')

    # importlib._bootstrap._resolve_name
    bits = package.rsplit('.', level - 1)
    if len(bits) < level: # package 以点号分割之后,至少应该有 level 个部分
        raise ImportError('attempted relative import beyond top-level package')
    base = bits[0] # base 为 package 去掉右边 level-1 个部分后剩下的字符串
    return '{}.{}'.format(base, name) if name else base

从 reslove_name 函数的逻辑可以看出,相对导入的锚点,是 import 语句所在模块所属的包。

builtins.__import__ 所依赖的 _find_and_load 函数在前面已经有比较详细的解读,下面是另外一个依赖函数 _handle_fromlist 的简化版本:

# importlib/_bootstrap.py

def _handle_fromlist(module, fromlist, import_, *, recursive=False):
    for x in fromlist:
        if x == '*':
            if not recursive and hasattr(module, '__all__'):
                _handle_fromlist(module, module.__all__, import_,
                                 recursive=True)
        elif not hasattr(module, x):
            from_name = '{}.{}'.format(module.__name__, x)
            import_(from_name)
    return module

可见 _handle_fromlist 会尽量让模块对象拥有 fromlist 中提供的属性,比如通过导入子模块的方式。fromlist 中的 ‘*’ 代表模块对象的 __all__ 属性定义的全局对象或子模块列表。(__all__ 缺失时,‘*’ 代表 mod 中所有名称不以下划线 ‘_’ 开头的全局对象,但暂未找到体现这一点的源码)

importlib.__import__

importlib 同时也提供了一个跨解释器的 __import__ 函数的 Python 实现,即 importlib.__import__。可执行 builtins.__import = importlib.__import__ 使得 import 语句来实际调用 importlib.__import__。

但需要注意的是,importlib.__import__ 并不是对 builtins.__import__ 的完全翻译,而是稍有不同的实现逻辑。这使得同样的一条 import 语句,在两种实现下的表现可能并不完全一样。

两者最大的不同,就是导入当前模块的过程中发现又需要导入其他模块时,builtins.__import__ 仍是使用自身,而 importlib.__import__ 会使用另外一个函数:importlib._gcd_import。

# importlib/_bootstrap.py

def _gcd_import(name, package=None, level=0):
    """Import and return the module based on its name, the package the call is
    being made from, and the level adjustment.

    This function represents the greatest common denominator of functionality
    between import_module and __import__. This includes setting __package__ if
    the loader did not.

    """
    _sanity_check(name, package, level)
    if level > 0:
        name = _resolve_name(name, package, level)
    return _find_and_load(name, _gcd_import)

现象解释

sys.path 很关键

  • 现象项目文件在 PyCharm 里面能正常运行,但在命令行里运行却报错:ModuleNotFoundError。

这种情况中被运行的文件一般不位于项目的根目录,而且其中有 import 语句中的模块名是从项目根目录下开始的。由于 PyCharm 会将项目根目录和文件所在目录都放进 sys.path中,所以 PyCharm 解析和运行都没有问题;而命令行运行时只有文件所在目录被放进sys.path中,这就导致从项目根目录下开始的模块名无法被找到。

其实,项目中所有用作运行入口的文件在导入项目内其他模块时,统一从项目根目录下开始书写模块名,是一种良好的实践。为解决命令行运行出错的问题,我们可以在运行文件之前设置环境变量 export PYTHONPATH=“/path/to/your_project/root”,亦可将此设置添加进虚拟环境的 active 文件中。

相对导入的锚点

  • 现象:文件能够被正常导入,但直接运行这个文件却报错:ImportError。

这种情况中被运行的文件一般含有相对导入语句。我们可能很直观地以为,相对导入的锚点是文件所在目录,而实际上相对导入的锚点是模块所属的包。这二者大部分情况下都是对应的,让我们以为二者没有区别,但实际上在一些情况下是不一样的,尤其是入口模块。

当 Python 运行文件时,会先构造一个入口模块对象,其 __package__ 与 __spec__ 属性为 None, __name__ 属性为 ‘__main__’,而其 __dict__ 属性则代表了进程的全局命名空间;然后在进程的全局命名空间中执行入口文件的代码。这就相当于在入口文件最前面添加了如上的全局变量。若执行代码的过程中,这三个属性/变量没有被更改,又遇到了相对导入语句,依据前面提到的 resolve_name 的逻辑,计算出 package = ‘’,于是抛出 ImportError: attempted relative import with no known parent package.

因此一般而言,如果要将一个文件作为运行入口,其中就不能包含相对导入语句。反过来说,如果一个文件包含相对导入语句,那它就只能被导入而不能作为运行入口。

  • 现象:文件内的相对导入语句在 PyCharm 中未报错,但如果运行导入该文件的其他文件,在执行到那句相对导入语句时报错:ImportError。

PyCharm 在解析相对导入语句时,无法预知文件在被执行时其所属包名,因此只能按文件系统的相对来解析。这样即使模块名前的点号多到超出项目根目录之上,IDE 也不会报错。但在运行项目文件时,所有遇到的相对导入语句都要先转换为绝对导入语句,这就要求相对导入语句所在模块的 __package__ 以点号分割之后,至少应该有 level 个部分,否则就会抛出 ImportError: attempted relative import beyond top-level package.

python -m 与 python 的区别

  • 现象:同一个模块/脚本,python -m 能运行,python 却不行;或者相反

我们一般用 python /path/to/script.py 的方式运行脚本,其机制在前文已经解释。但如果我们希望将模块当作脚本来运行,而又不想去手动寻找它在文件系统中的位置,就可使用 python -m mod.full.name 的方式让 python 自己去寻找模块的位置。比如 python -m http.server 可以快速地起一个提供目录展示的简易网络服务器,python -m pydoc -p 9000 可以快速地起一个文档阅读服务器。

python -m mod.full.name 相当于:

  1. 在当前工作目录起一个交互式会话(所以当前工作目录会被添加至sys.path中)
  2. 对目标模块的父模块执行标准的__import__导入操作:寻找+加载
  3. 找到目标模块的spec。若发现它是包,则转换目标为其__main__子模块(类似于 python /path/to/dir 等同于 python /path/to/dir/__main__.py)
  4. 使用spec加载模块对象时,先将其__name__属性改为’__main__',而其他属性如__package__,__spec__基本不变,然后再执行模块代码

python -m 与直接运行脚本的方式相同之处在于:

  • 模块代码均会被执行
  • 由于模块对象的__name__属性均被更改为’__main__‘,所以模块文件内的 if __name__==’__main__’ 均会成立。

而不同之处在于:

  • python -m 时被添加到 sys.path之中的,不是脚本的目录,而是运行命令的工作目录
  • python -m 会先行导入父模块,直接运行脚本则不会
  • python -m 不会更改模块对象的__package__、__spec__属性,所以模块文件中的相对导入语句仍能正常工作;而直接运行脚本时,模块对象的__package__、__spec__被更改为 None,所以模块文件中的相对导入语句不能正常工作。

正是这些不同之处导致了一些看似怪异的现象。举例如下:

$ tree
.
└── pkg
    ├── mod1.py
    ├── mod2.py
    └── mod3.py

$ cat pkg/mod1.py
from . import mod3

$ cat pkg/mod2.py
import mod3

$ python pkg/mod1.py
Traceback (most recent call last):
  File ".../pkg/mod1.py", line 1, in <module>
    from . import mod3
ImportError: attempted relative import with no known parent package
# 含有相对导入语句,一定不能直接运行

$ python -m pkg.mod1 # 正常运行 

$ python pkg/mod2.py # 正常运行

$ python -m pkg.mod2
Traceback (most recent call last):
  File "<frozen runpy>", line 198, in _run_module_as_main
  File "<frozen runpy>", line 88, in _run_code
  File ".../pkg/mod2.py", line 1, in <module>
    import mod3
ModuleNotFoundError: No module named 'mod3'

# pkg/mod1.py 中 "import mod3" 要能正常执行,依赖于 sys.path 中存在 pkg/;
# 如果直接运行脚本 mod2.py,无论在哪里运行,这一点都能保证;
# 如果用 python -m 的方式运行,则只有工作目录被添加进 sys.path 中,此时运行命令的位置就会导致不同的效果了:

$ cd pkg

$ python -m mod2 # 正常运行

$ python -m mod1
Traceback (most recent call last):
  File "<frozen runpy>", line 198, in _run_module_as_main
  File "<frozen runpy>", line 88, in _run_code
  File ".../pkg/mod1.py", line 1, in <module>
    from . import mod3
ImportError: attempted relative import with no known parent package
# 这里报错是因为此时 mod1 是顶层模块,其__package__为''

其他导入方式

我们一般不会直接直接使用 builtins.__import__ 、importlib.__import__ 等函数。但除了最常用的 import 语句,我们还有其他一些模块导入方式,以满足一些特殊需求。

importlib.import_module

importlib.import_module(mod_name: str, package: str = None) -> types.ModuleType

此函数会像 import 语句一样分析相对导入,然后调用 _find_and_load 函数进行寻找和加载模块的工作(所以如果在sys.modules缓存中找到了模块,也不会再次执行模块代码),但不会像 import 语句一样直接按模块名进行名称绑定。

正是因为 import 语句要进行名称绑定,所以它要求模块全名中不能出现’-'等字符,以免影响以模块名作为对象名称。而使用 importlib.import_module 函数就可以绕开这个问题。

importlib.reload

importlib.reload(mod: types.ModuleType) -> types.ModuleType

此函数传入一个 sys.modules 中已存在的模块对象,调用 _find_spec 重新取得这个模块的 spec,然后再执行最新的模块文件的代码。

我们知道使用 import 语句和 importlib.import_module 多次导入同一模块,模块代码只会在第一次导入时执行。但如果我们真的需要多次执行模块代码,或者要体现代码的变化,就可以使用 reload 函数。

没用的冷知识

当你偏要导入__init__

在 sys.path 中的某目录下:

  • pkg.py 文件 或 pkg/ 目录存在时:from pkg import __init__ ,__init__ 为一个 ‘method-wrapper’ ,是模块对象的 __init__ 方法。
  • pkg/__init__.py 文件存在时:import pkg.__init__ 导入包模块+普通文件模块,且 __init__.py 的代码会执行两次。

一道思考题

我们可以看到 Python 的模块导入十分依赖模块对象的一些魔术属性以及一些系统变量,而这些属性和变量都是可以更改的。如果我们人造一些属性和变量,就可以做一些实验来验证前面提到的各种逻辑。

假设在 main.py 同目录下存在一个空的 mod.py 文件。

如果 main.py 的内容如下,则运行 main.py 会报错 ModuleNotFoundError: No module named ‘my’。

import sys

ModuleType = type(sys)
pkg = ModuleType('my.pkg')
pkg.__path__ = ['.']

sys.modules['my.pkg'] = pkg

from my.pkg import mod

但是如果我们用 importlib.__import__ 替代 builtins.__import__ ,就会按预期正常运行。

import sys
import builtins
import importlib

builtins.__import__ = importlib.__import__

ModuleType = type(sys)
pkg = ModuleType('my.pkg')
pkg.__path__ = ['.']

sys.modules['my.pkg'] = pkg

from my.pkg import mod

然后,如果我们把第一个版本的 main.py 稍加修改,即捕获第一次异常,然后重新执行相同的 import 语句,第二次却不抛异常了!

import sys

ModuleType = type(sys)
pkg = ModuleType('my.pkg')
pkg.__path__ = ['.']

sys.modules['my.pkg'] = pkg

try:
    from my.pkg import mod
except ModuleNotFoundError:
    from my.pkg import mod

这到底是为什么呢?

其他参考文档

  • 8
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值