如何使用sys.path_hooks实现自定义模块加载?

9

希望以下问题不会太长。否则我无法解释我的问题和需求:

如何使用importlib从任意来源导入模块?(昨天的问题)中学到, 我编写了一个特定的加载器来处理新文件类型(.xxx)。 (实际上,xxx是pyc的加密版本,用于保护代码免受盗窃)。

我希望只是为新文件类型"xxx"添加一个导入钩子,而不以任何方式影响其他类型(.py、.pyc、.pyd)。

现在,加载器是ModuleLoader,继承自mportlib.machinery.SourcelessFileLoader

使用sys.path_hooks,加载器将被添加为一个钩子:

myFinder = importlib.machinery.FileFinder
loader_details = (ModuleLoader, ['.xxx'])
sys.path_hooks.append(myFinder.path_hook(loader_details))

注意:这是通过调用modloader.activateLoader()来激活的。
加载名为test(它是一个test.xxx)的模块时,我会得到:
>>> import modloader
>>> modloader.activateLoader()
>>> import test
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ImportError: No module named 'test'
>>>

然而,在添加钩子之前删除sys.path_hooks的内容:

sys.path_hooks = []
sys.path.insert(0, '.') # current directory
sys.path_hooks.append(myFinder.path_hook(loader_details))

它有效:

>>> modloader.activateLoader()
>>> import test
using xxx class

in xxxLoader exec_module
in xxxLoader get_code: .\test.xxx
ANALYZING ...

GENERATE CODE OBJECT ...

  2           0 LOAD_CONST               0
              3 LOAD_CONST               1 ('foo2')
              6 MAKE_FUNCTION            0
              9 STORE_NAME               0 (foo2)
             12 LOAD_CONST               2 (None)
             15 RETURN_VALUE
>>>>>> test
<module 'test' from '.\\test.xxx'>

在将文件内容转换为代码对象后,模块已正确导入。 但是我无法从包中加载相同的模块:import pack.test 注意:pack目录中的__init__.py当然是一个空文件。
>>> import pack.test
Traceback (most recent call last):
  File "<frozen importlib._bootstrap>", line 2218, in _find_and_load_unlocked
AttributeError: 'module' object has no attribute '__path__'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ImportError: No module named 'pack.test'; 'pack' is not a package
>>>

不够用,我不能再从该软件包中加载普通的 *.py 模块:我会得到与上述相同的错误:

>>> import pack.testpy
Traceback (most recent call last):
  File "<frozen importlib._bootstrap>", line 2218, in _find_and_load_unlocked
AttributeError: 'module' object has no attribute '__path__'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ImportError: No module named 'pack.testpy'; 'pack' is not a package
>>>

根据我的理解,sys.path_hooks会遍历直到尝试最后一个条目。那么为什么第一种变体(没有删除sys.path_hooks)不能识别新的扩展名“xxx”,而第二种变体(删除sys.path_hooks)可以呢?当sys.path_hooks的一个条目无法识别“xxx”时,似乎机制会抛出异常而不是继续遍历到下一个条目。
为什么第二个版本在当前目录中的py、pyc和xxx模块可以正常工作,但在包pack中却不能正常工作?我本来以为在当前目录中甚至py和pyc也无法正常工作,因为sys.path_hooks只包含了一个“xxx”的钩子...

不使用Python 3,所以无法在这里提供帮助 - 但是如果您总结一下您的问题,那将有助于其他人回答。也许尝试sys.path_hooks.insert(0, myFinder.path_hook(loader_details))?问题仍然存在。 - Mr_and_Mrs_D
3个回答

10
简而言之,sys.meta_path 中的默认 PathFinder 并不支持在已经支持的路径中添加新的文件扩展名和导入程序。但仍有希望! sys.path_hooksimportlib._bootstrap_external.PathFinder 类所使用。
当进行导入时,会要求 sys.meta_path 中的每个条目查找所需模块的匹配规范。特别是 PathFinder 将获取 sys.path 的内容并将其传递给 sys.path_hooks 中的工厂函数。每个工厂函数都有机会引发 ImportError(基本上是工厂说“不,我不支持这个路径条目”)或返回该路径的 finder 实例。第一个成功返回的 finder 然后会缓存在 sys.path_importer_cache 中。从那时起,PathFinder 只会询问这些缓存的 finder 实例是否可以提供所需的模块。
如果您查看 sys.path_importer_cache 的内容,您会看到来自 sys.path 的所有目录条目都已映射到 FileFinder 实例。非目录条目(zip 文件等)将映射到其他查找器。
因此,如果您通过 FileFinder.path_hook 创建一个新的工厂并将其附加到 sys.path_hooks,则仅当先前的 FileFinder hook 不接受该路径时才会调用您的工厂。这是不太可能的,因为 FileFinder 可以处理任何现有目录。
或者,如果您将新的工厂插入到现有工厂之前的 sys.path_hooks 中,则仅当您的新工厂不接受该路径时才会使用默认钩子。同样,由于 FileFinder 对其支持的内容非常宽松,因此这将导致仅使用您的加载器,就像您已经观察到的那样。
让它起作用:
因此,您可以尝试调整现有的工厂以支持您的文件扩展名和导入程序(这很困难,因为导入程序和扩展字符串元组保存在闭包中),或者像我一样添加一个新的元路径查找器。
例如,从我的项目中:

import sys

from importlib.abc import FileLoader
from importlib.machinery import FileFinder, PathFinder
from os import getcwd
from os.path import basename

from sibilant.module import prep_module, exec_module


SOURCE_SUFFIXES = [".lspy", ".sibilant"]


_path_importer_cache = {}
_path_hooks = []


class SibilantPathFinder(PathFinder):
    """
    An overridden PathFinder which will hunt for sibilant files in
    sys.path. Uses storage in this module to avoid conflicts with the
    original PathFinder
    """


    @classmethod
    def invalidate_caches(cls):
        for finder in _path_importer_cache.values():
            if hasattr(finder, 'invalidate_caches'):
                finder.invalidate_caches()


    @classmethod
    def _path_hooks(cls, path):
        for hook in _path_hooks:
            try:
                return hook(path)
            except ImportError:
                continue
        else:
            return None


    @classmethod
    def _path_importer_cache(cls, path):
        if path == '':
            try:
                path = getcwd()
            except FileNotFoundError:
                # Don't cache the failure as the cwd can easily change to
                # a valid directory later on.
                return None
        try:
            finder = _path_importer_cache[path]
        except KeyError:
            finder = cls._path_hooks(path)
            _path_importer_cache[path] = finder
        return finder


class SibilantSourceFileLoader(FileLoader):


    def create_module(self, spec):
        return None


    def get_source(self, fullname):
        return self.get_data(self.get_filename(fullname)).decode("utf8")


    def exec_module(self, module):
        name = module.__name__
        source = self.get_source(name)
        filename = basename(self.get_filename(name))

        prep_module(module)
        exec_module(module, source, filename=filename)


def _get_lspy_file_loader():
    return (SibilantSourceFileLoader, SOURCE_SUFFIXES)


def _get_lspy_path_hook():
    return FileFinder.path_hook(_get_lspy_file_loader())


def _install():
    done = False

    def install():
        nonlocal done
        if not done:
            _path_hooks.append(_get_lspy_path_hook())
            sys.meta_path.append(SibilantPathFinder)
            done = True

    return install


_install = _install()
_install()

SibilantPathFinder重写了PathFinder并替换了仅引用sys.path_hooksys.path_importer_cache的方法,将其替换为类似实现,而这些实现则查找本模块局部的_path_hook_path_importer_cache

在导入期间,现有的PathFinder会尝试找到匹配的模块。如果找不到,则我注入的SibilantPathFinder将重新遍历sys.path并尝试找到与我的某个文件扩展名匹配的模块。

更多信息的搞清楚

最终我深入研究了_bootstrap_external模块的源代码, https://github.com/python/cpython/blob/master/Lib/importlib/_bootstrap_external.py

_install函数和PathFinder.find_spec方法是了解事情发生原因的最佳起点。


9
@obriencj的分析是正确的。但我提出了一个不需要将任何内容放入sys.meta_path的问题解决方案。相反,它在sys.path_hooks中安装了一个特殊的钩子,几乎像是在sys.meta_path中的PathFinder和sys.path_hooks中的钩子之间充当中间件,而不仅仅是使用第一个说“我可以处理这个路径!”的钩子,它按顺序尝试所有匹配的钩子,直到找到一个实际从其find_spec方法返回有用ModuleSpec为止:
@PathEntryFinder.register
class MetaFileFinder:
    """
    A 'middleware', if you will, between the PathFinder sys.meta_path hook,
    and sys.path_hooks hooks--particularly FileFinder.

    The hook returned by FileFinder.path_hook is rather 'promiscuous' in that
    it will handle *any* directory.  So if one wants to insert another
    FileFinder.path_hook into sys.path_hooks, that will totally take over
    importing for any directory, and previous path hooks will be ignored.

    This class provides its own sys.path_hooks hook as follows: If inserted
    on sys.path_hooks (it should be inserted early so that it can supersede
    anything else).  Its find_spec method then calls each hook on
    sys.path_hooks after itself and, for each hook that can handle the given
    sys.path entry, it calls the hook to create a finder, and calls that
    finder's find_spec.  So each sys.path_hooks entry is tried until a spec is
    found or all finders are exhausted.
    """

    class hook:
        """
        Use this little internal class rather than a function with a closure
        or a classmethod or anything like that so that it's easier to
        identify our hook and skip over it while processing sys.path_hooks.
        """

        def __init__(self, basepath=None):
            self.basepath = os.path.abspath(basepath)

        def __call__(self, path):
            if not os.path.isdir(path):
                raise ImportError('only directories are supported', path=path)
            elif not self.handles(path):
                raise ImportError(
                    'only directories under {} are supported'.format(
                        self.basepath), path=path)

            return MetaFileFinder(path)

        def handles(self, path):
            """
            Return whether this hook will handle the given path, depending on
            what its basepath is.
            """

            path = os.path.abspath(path)

            return (self.basepath is None or
                    os.path.commonpath([self.basepath, path]) == self.basepath)

    def __init__(self, path):
        self.path = path
        self._finder_cache = {}

    def __repr__(self):
        return '{}({!r})'.format(self.__class__.__name__, self.path)

    def find_spec(self, fullname, target=None):
        if not sys.path_hooks:
            return None

        last = len(sys.path_hooks) - 1

        for idx, hook in enumerate(sys.path_hooks):
            if isinstance(hook, self.__class__.hook):
                continue

            finder = None
            try:
                if hook in self._finder_cache:
                    finder = self._finder_cache[hook]
                    if finder is None:
                        # We've tried this finder before and got an ImportError
                        continue
            except TypeError:
                # The hook is unhashable
                pass

            if finder is None:
                try:
                    finder = hook(self.path)
                except ImportError:
                    pass

            try:
                self._finder_cache[hook] = finder
            except TypeError:
                # The hook is unhashable for some reason so we don't bother
                # caching it
                pass

            if finder is not None:
                spec = finder.find_spec(fullname, target)
                if (spec is not None and
                        (spec.loader is not None or idx == last)):
                    # If no __init__.<suffix> was found by any Finder,
                    # we may be importing a namespace package (which
                    # FileFinder.find_spec returns in this case).  But we
                    # only want to return the namespace ModuleSpec if we've
                    # exhausted every other finder first.
                    return spec

        # Module spec not found through any of the finders
        return None

    def invalidate_caches(self):
        for finder in self._finder_cache.values():
            finder.invalidate_caches()

    @classmethod
    def install(cls, basepath=None):
        """
        Install the MetaFileFinder in the front sys.path_hooks, so that
        it can support any existing sys.path_hooks and any that might
        be appended later.

        If given, only support paths under and including basepath.  In this
        case it's not necessary to invalidate the entire
        sys.path_importer_cache, but only any existing entries under basepath.
        """

        if basepath is not None:
            basepath = os.path.abspath(basepath)

        hook = cls.hook(basepath)
        sys.path_hooks.insert(0, hook)
        if basepath is None:
            sys.path_importer_cache.clear()
        else:
            for path in list(sys.path_importer_cache):
                if hook.handles(path):
                    del sys.path_importer_cache[path]

这仍然是令人沮丧的,比必要的复杂性更多。我觉得在Python 2之前,导入系统重写之前,做这件事情要简单得多,因为支持内置模块类型(.py等)的较少部分是建立在导入钩子本身之上的,所以通过添加导入新模块类型的钩子来破坏导入普通模块更加困难。我将在python-ideas上开始讨论,看看是否有任何方法可以改善这种情况。


不错!@Iguananaut,你的解决方案有什么优势呢?问题的核心似乎是Python从sys.path_hooks缓存PathEntryFinders时没有检查它们是否能够加载所有所需的模块。@obriencj的解决方案似乎是避免基于路径的子系统,通过元路径更好地重新实现它。而你则是在某种程度上修补了普通的PathFinder,使其再次规避了过早的缓存?此外,你能否解释一下install函数?我不明白是谁调用它以及它覆盖了什么(来自ABCMeta还是PathEntryFinder的东西?)。 - Michele Piccolini
@MichelePiccolini 我已经不太记得这里的细节了。如果我没记错,我想用它来处理那些不是标准 Python 扩展名的文件类型 ( 比如 .pyx 文件 ) 的导入。install 方法将被任何需要使用此扩展的代码调用到导入系统中。因此,在你自己的代码中,在尝试导入任何“非标准”模块之前,你应该运行 MetaFileFinder.install() - Iguananaut
1
很遗憾,在Python端仍然很难做到这一点。也许我应该提出一个问题(我记得在写这篇文章的同时发布了一个关于这个问题的python-ideas帖子,但似乎没有人感兴趣)。 - Iguananaut
2
谢谢@Iguananaut!我稍微尝试了一下你的代码,现在完全理解它的作用了。我同意这样做似乎很麻烦,只是为了避免Python的某种行为,看起来像是“过于激进的缓存”。 - Michele Piccolini

2

我想到了另一种替代方法。 我不会说它很美丽,因为它对已经存在的闭包进行了调整,但至少代码短 :)

通过新钩子向默认的FileLoader对象添加加载器。原始的path_hook_for_FileFinder被封装在一个闭包中,并将加载器注入到原始钩子返回的FileFinder对象中。

在新钩子添加后,path_importer_cache被清除,因为它已经填充了原始的FileFinder对象。这些也可以动态更新,但目前我没有烦恼。

免责声明:尚未进行广泛测试。 在我所知道的最简单的方式中,它以我需要的方式完成了任务,但是导入系统足够复杂,以至于像这样的微调可能会产生有趣的副作用。

import sys
import importlib.machinery

def extend_path_hook_for_FileFinder(*loader_details):

    orig_hook, orig_pos = None, None

    for i, hook in enumerate(sys.path_hooks):
        if hook.__name__ == 'path_hook_for_FileFinder':
            orig_hook, orig_pos = hook, i
            break

    sys.path_hooks.remove(orig_hook)

    def extended_path_hook_for_FileFinder(path):
        orig_finder = orig_hook(path)

        loaders = []
        for loader, suffixes in loader_details:
            loaders.extend((suffix, loader) for suffix in suffixes)
        
        orig_finder._loaders.extend(loaders)

        return orig_finder

    sys.path_hooks.insert(orig_pos, extended_path_hook_for_FileFinder)


MY_SUFFIXES = ['.pymy']

class MySourceFileLoader(importlib.machinery.SourceFileLoader):
    pass

loader_detail = (MySourceFileLoader, MY_SUFFIXES)

extend_path_hook_for_FileFinder(loader_detail)

# empty cache as it is already filled with simple FileFinder
# objects for the most common path elements
sys.path_importer_cache.clear()
sys.path_importer_cache.invalidate_caches()


非常聪明。虽然比Iguananaut已经聪明的解决方案更简洁,但这种方法至少需要违反隐私封装两次:一次是通过假设importlib._bootstrap_external.FileFinder.path_hook()返回名为path_hook_for_FileFinder的闭包(这在未来的Python版本中并不保证),再次是通过访问私有的FileFinder._loaders列表。任何这样做的人都应该进行广泛的单元测试,以验证它确实做到了你认为它做到的事情。 - Cecil Curry
1
相关地:你绝对不想sys.path_importer_cache = {}。没有人希望任何人采取像那样的激进措施,所以你也不应该这么做。如果任何其他代码引用先前的缓存对象,现在你已经让他们失去了依赖。相反,你应该先调用sys.path_importer_cache.clear(),然后调用importlib.invalidate_caches()。这样做可以保留现有的缓存对象,同时实现预期的效果。 - Cecil Curry
感谢@CecilCurry,提出了很好的观点,我会尝试设计更具未来性的解决方案。目前我只是使用这个方法来实现一些本应该由导入系统支持的功能。正确清除缓存肯定比我的硬删除更好,我会加以整合! - Sz'

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接