PEP302 Import Hook的一些背景知识

_import_ 是python的动态导入函数
我们看 PEP302 这个提案。PEP302的主要内容摘要
提供 import hook 函数以替代传统的 _import_ ,以便更好地定制import 的过程,对导入过程实现更精细的控制。

对于 Python的 Import机制,大致可以简化成几个过程。

  • 名字规范化
    处理相对导入(level)并把目标变成绝对模块名 fullname。

  • 检查缓存(sys.modules)
    如果 fullname 已在 sys.modules 中,直接返回该模块对象(避免重复加载)。

  • 查找模块(Finder)
    Python 遍历 sys.meta_path 中的查找器(meta path finders),调用每个 finder 的 find_spec(fullname, path, target)(现代)或旧的 find_module。
    如果 meta-path 没找到,再根据包的 path 使用 sys.path_hooks 与 sys.path_importer_cache 去查找(这用于 import pkg.submod 时按包路径查找)。

  • 获得模块规范(ModuleSpec)
    Finder 返回一个 ModuleSpec(包含 loader、origin、submodule_search_locations 等信息)。

  • 使用 Loader 创建并执行模块
    Loader 的职责是创建模块对象(create_module(spec),可以返回 None 表示使用默认创建器)并执行模块代码(exec_module(module))来填充模块命名空间。
    在旧接口里对应的是 load_module(fullname)。

  • 模块写入 sys.modules
    在加载过程中模块会被放入 sys.modules,以便循环引用时能找到(通常在 exec 前或 exec 期间就已放入,细节由实现决定)。

  • 返回模块对象
    import 表达式最终返回模块对象或绑定到目标名。

对于一般的 pip安装在本地的模块来说,上面的步骤大致可以理解为,根据名称从磁盘目录加载模块并规范为一个module,放在内存中。

但是很常见的误解开始在这个地方显现

import机制不仅仅是仅能加载操作系统本地路径的查找器,它还可以加载网络模块,zip文件,以及数据库,甚至是内存数据,或者动态生成

一个演示加载的代码示例

# demo_import_steps.py
import sys
import importlib
import importlib.util

name = "math"   # 你可以换成任意模块
print("在 import 前 sys.modules 中有吗:", name in sys.modules)

# 1. 使用 importlib.util.find_spec 查看模块的 spec(查找阶段)
spec = importlib.util.find_spec(name)
print("spec:", spec)               # None 表示找不到
if spec:
    print("origin:", spec.origin)
    print("loader:", spec.loader)

# 2. 如果未在 sys.modules,演示如何用 spec 手动创建模块并执行
if name not in sys.modules and spec and spec.loader:
    mod = importlib.util.module_from_spec(spec)
    # 将模块放入 sys.modules(导入系统通常在 exec 前或中间做这步)
    sys.modules[name] = mod
    # 执行模块代码(loader 负责)
    spec.loader.exec_module(mod)
    print("现在 sys.modules 有了吗:", name in sys.modules)

# 3. 标准 import(等效)
import math
print("math.pi =", math.pi)

输出

在 import 前 sys.modules 中有吗: False
spec: ModuleSpec(name='math', loader=<_frozen_importlib_external.ExtensionFileLoader object at 0x7fba1013a970>, origin='/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/lib-dynload/math.cpython-38-darwin.so')
origin: /Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/lib-dynload/math.cpython-38-darwin.so
loader: <_frozen_importlib_external.ExtensionFileLoader object at 0x7fba1013a970>
现在 sys.modules 有了吗: True
math.pi = 3.141592653589793
上面这个导入,用了比较现代的 find_spec 这些钩子函数。
回过头来看 PEP302 对于import的改善动机就不难理解了。

导入 import 的旧的范式,如果涉及到重写这个过程,做法就是重新定义实现 _import_ 函数

这样的话,我们必须小心翼翼,以免破坏原有的导入机制。
一旦开始重新,我们要搞清楚整个导入过程,然后再仔细加入我们自定义的东西;
另外,即使对于 sys.modules 中已经存在的模块,也会调用此方法,这几乎永远不是你想要的结果,除非你在编写某种监控工具。

PEP302 定义的两个Protocol: finder 和loader

finder只有一个方法 find_module(fullname, path=None)
loader 也只有一个方法 loader.load_module(fullname) load_module
(PEP451增加了create_module和exec_module)

PEP中示例的一个 mini版本的 load_module

# Consider using importlib.util.module_for_loader() to handle
# most of these details for you.
def load_module(self, fullname):
    code = self.get_code(fullname)
    ispkg = self.is_package(fullname)
    mod = sys.modules.setdefault(fullname, imp.new_module(fullname))
    mod.__file__ = "<%s>" % self.__class__.__name__
    mod.__loader__ = self
    if ispkg:
        mod.__path__ = []
        mod.__package__ = fullname
    else:
        mod.__package__ = fullname.rpartition('.')[0]
    exec(code, mod.__dict__)
    return mod

三类 hook的作用

Hook 在哪里注册 作用
Meta Path Finder sys.meta_path 找模块入口(全局)
Path Entry Finder sys.path_hooks 针对每个路径处理(目录/zip/网络)
Loader Finder 返回 loader 加载模块(执行代码、构造模块对象)

import something
└── sys.meta_path(Meta Finders)
└── 大部分模块交给 PathFinder

PathFinder
├── sys.path 遍历每个路径
├── 为每个 path 创建 PathEntryFinder(由 sys.path_hooks 决定)
└── PathEntryFinder.find_spec
└── 返回 ModuleSpec(含 Loader)
└── Loader.exec_module() 执行模块代码

示例:为特殊前缀路径注册 Finder

import sys

class VirtualPathFinder:
    def __init__(self, path):
        self.path = path

    def find_spec(self, fullname, target=None):
        if fullname == "hello":
            from importlib.util import spec_from_loader
            return spec_from_loader("hello", loader=None)
        return None

def hook(path):
    if path.startswith("virtual:"):
        return VirtualPathFinder(path)
    raise ImportError

sys.path_hooks.insert(0, hook)

sys.path.insert(0, "virtual:/memory")

自定义 Loader 加载字符串为模块

import sys, types, importlib.util

class StringLoader:
    def create_module(self, spec):
        return None  # 使用默认 module 创建逻辑

    def exec_module(self, module):
        code = "x = 123\ny = 'hello'"
        exec(code, module.__dict__)

class SimpleFinder:
    def find_spec(self, fullname, path, target=None):
        if fullname == "mymodule":
            spec = importlib.util.spec_from_loader(fullname, StringLoader())
            return spec
        return None

sys.meta_path.insert(0, SimpleFinder())
# in_memory_importer.py
import sys
import importlib.abc
import importlib.util
import types

# 1) 我们的“虚拟文件系统” —— 模块名 -> 源代码
VFS = {
    "virtual.mod1": """
value = 123
def hello():
    return "hello from virtual.mod1"
""",
    "virtual.subpkg.mod2": """
from ..mod1 import value
def get():
    return f"mod2 sees {value}"
"""
}

# 2) 实现 Finder(MetaPathFinder)
class VFSFinder(importlib.abc.MetaPathFinder):
    def find_spec(self, fullname, path, target=None):
        # 如果我们的 VFS 中有该模块名,则返回一个 ModuleSpec
        if fullname in VFS:
            loader = VFSLoader(fullname)
            # submodule_search_locations 用于把它当作包(如果需要)——这里不设置包搜索
            return importlib.util.spec_from_loader(fullname, loader, origin="vfs")
        # 支持把 virtual 包作为包(使 virtual.subpkg 可作为包)
        if fullname in ("virtual", "virtual.subpkg"):
            # 标记为 package(设置 submodule_search_locations 为 list)
            loader = VFSLoader(fullname)
            return importlib.util.spec_from_loader(fullname, loader, origin="vfs", is_package=True)
        return None

# 3) 实现 Loader(继承抽象基类)
class VFSLoader(importlib.abc.Loader):
    def __init__(self, name):
        self.name = name

    def create_module(self, spec):
        # 返回 None 表示使用默认 module 创建器(types.ModuleType)
        return None

    def exec_module(self, module):
        # 在 exec_module 中执行模块代码,填充 module.__dict__
        src = VFS.get(self.name)
        if src is None:
            # 如果是 package 且没有源,允许留空,或者可以 set __path__ 等
            if getattr(module, "__package__", None):
                module.__path__ = []  # 代表这是个 package(示例)
                return
            raise ImportError(f"No source for {self.name}")
        # 执行源代码(注意在真实场景需要考虑安全)
        code = compile(src, f"<vfs:{self.name}>", "exec")
        exec(code, module.__dict__)

# 4) 安装到 sys.meta_path(前置优先)
sys.meta_path.insert(0, VFSFinder())

# 5) 使用示例
if __name__ == "__main__":
    import virtual.mod1
    print("virtual.mod1.value:", virtual.mod1.value)
    print("virtual.mod1.hello():", virtual.mod1.hello())

    # 导入子包模块示例(需要支持 package)
    import virtual.subpkg.mod2
    print("virtual.subpkg.mod2.get():", virtual.subpkg.mod2.get())

一个能加载 md 文件_markdown 的 import hook (来自AI)

能加载 markdown 文件的 import hook”

# md_import_hook.py
import sys
import os
import importlib.abc
import importlib.util
import types

# 可选:尝试导入 markdown 库以转换为 HTML(非必须)
try:
    import markdown
except Exception:
    markdown = None

class MarkdownLoader(importlib.abc.Loader):
    """
    Loader:负责把 .md 文件的内容载入模块命名空间。
    模块将包含:
      - __file__ :源文件路径
      - __text__ :原始 Markdown 文本
      - __html__ :如果能转换则为 HTML 字符串,否则 None
    """
    def __init__(self, fullname, path, is_package=False):
        self.fullname = fullname
        self.path = path
        self.is_package = is_package

    def create_module(self, spec):
        # 使用默认的模块创建(types.ModuleType)。返回 None 表示使用默认创建器。
        return None

    def exec_module(self, module):
        # 执行模块:读取 .md 内容并注入模块命名空间
        module.__file__ = self.path
        module.__package__ = self.fullname if self.is_package else self.fullname.rpartition('.')[0]
        if self.is_package:
            # 简单设置 __path__ (允许子模块继续通过 finder 找到基于此包的子模块)
            module.__path__ = [os.path.dirname(self.path)]
        with open(self.path, 'r', encoding='utf-8') as f:
            text = f.read()
        module.__text__ = text
        if markdown:
            # 若安装了 markdown 包则转换为 html
            module.__html__ = markdown.markdown(text)
        else:
            module.__html__ = None

class MarkdownMetaFinder(importlib.abc.MetaPathFinder):
    """
    MetaPathFinder:在 sys.path 每个 entry 下查找 <path_parts>.md 或 package dir with __init__.md
    支持:
      - import a.b.c  -> search for a/b/c.md
      - packages: if a/b is a dir and contains __init.md (or __init__.md), treat as package
    """
    def __init__(self, suffix='.md'):
        self.suffix = suffix

    def _find_in_path(self, fullname, path_entry):
        """
        在给定的 sys.path 条目下查找 fullname 对应的 .md 文件或 package 的 __init__.md
        返回 (found_path, is_package) 或 (None, False)
        """
        parts = fullname.split('.')
        # 1) 先尝试把 fullname 作为普通模块文件: e.g. <path_entry>/a/b/c.md
        candidate = os.path.join(path_entry, *parts) + self.suffix
        if os.path.isfile(candidate):
            return candidate, False

        # 2) 再尝试作为 package:目录存在且目录下有 __init.md 或 __init__.md
        pkg_dir = os.path.join(path_entry, *parts)
        if os.path.isdir(pkg_dir):
            for init_name in ('__init' + self.suffix, '__init__.md'):
                init_path = os.path.join(pkg_dir, init_name)
                if os.path.isfile(init_path):
                    return init_path, True

        return None, False

    def find_spec(self, fullname, path, target=None):
        # 忽略内置或顶层名字过短的情况(可根据需要放宽)
        # 在每个 sys.path 条目查找
        for entry in sys.path:
            # 忽略非文件系统 entry(比如 zipimport 可能是 zip 路径),但我们仍尝试路径字符串
            if not isinstance(entry, str):
                continue
            # fast skip
            if not os.path.exists(entry):
                continue
            found, is_pkg = self._find_in_path(fullname, entry)
            if found:
                loader = MarkdownLoader(fullname, found, is_package=is_pkg)
                # 如果是 package,需要设置 submodule_search_locations
                if is_pkg:
                    spec = importlib.util.spec_from_loader(fullname, loader, origin=found, is_package=True)
                    # 指定 submodule_search_locations (这样 import a.b.c 会在 a 的 __path__ 下继续查找)
                    spec.submodule_search_locations = [os.path.dirname(found)]
                else:
                    spec = importlib.util.spec_from_loader(fullname, loader, origin=found)
                return spec
        # 没找到则返回 None,让下一个 finder 处理
        return None

# 安装到 sys.meta_path(放到最前面以优先处理 .md)
_finder = MarkdownMetaFinder()
if _finder not in sys.meta_path:
    sys.meta_path.insert(0, _finder)

# ------------------------------
# 下面是一个自包含的 demo:创建临时 md 文件并演示 import
# 只在作为脚本执行时运行(import 时不运行)
if __name__ == '__main__':
    import tempfile, shutil

    demo_dir = os.path.join(os.path.dirname(__file__), 'md_demo')
    os.makedirs(demo_dir, exist_ok=True)

    # 创建一个普通模块 md: md_demo/hello.md -> import hello
    with open(os.path.join(demo_dir, 'hello.md'), 'w', encoding='utf-8') as f:
        f.write("# Hello\nThis is a markdown module.\n\n- item1\n- item2\n")

    # 创建一个 package: md_demo/pkg/__init.md 以及 pkg/sub.md
    pkg_dir = os.path.join(demo_dir, 'pkg')
    os.makedirs(pkg_dir, exist_ok=True)
    with open(os.path.join(pkg_dir, '__init.md'), 'w', encoding='utf-8') as f:
        f.write("# pkg init\nvalue = 42\n")
    with open(os.path.join(pkg_dir, 'sub.md'), 'w', encoding='utf-8') as f:
        f.write("This is sub module inside pkg.\n")

    # 把 demo_dir 放入 sys.path(优先)
    if demo_dir not in sys.path:
        sys.path.insert(0, demo_dir)

    # 演示导入
    print("sys.path[0] = ", sys.path[0])
    import hello
    print("hello.__file__ =", hello.__file__)
    print("hello.__text__[:60] =", hello.__text__[:60].replace('\n','\\n'))
    print("hello.__html__ is None? ", hello.__html__ is None)

    import pkg
    print("pkg.__file__ =", pkg.__file__)
    print("pkg.__path__ =", getattr(pkg, '__path__', None))
    print("pkg.__text__[:40] =", pkg.__text__[:40].replace('\n','\\n'))

    import pkg.sub
    print("pkg.sub.__file__ =", pkg.sub.__file__)
    print("pkg.sub.__text__[:40] =", pkg.sub.__text__[:40].replace('\n','\\n'))

    # cleanup optional (取消注释若想删除 demo 文件)
    # shutil.rmtree(demo_dir)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容