如何在运行时检查 python 模块是否有效而不导入它?

How can I check on runtime that a python module is valid without importing it?

我有一个包含子包的包,其中只有一个我需要在运行时导入 - 但我需要测试它们是否有效。这是我的文件夹结构:

game/
 __init__.py
 game1/
   __init__.py
   constants.py
   ...
 game2/
   __init__.py
   constants.py
   ...

现在启动时运行的代码:

import pkgutil
import game as _game
# Detect the known games
for importer,modname,ispkg in pkgutil.iter_modules(_game.__path__):
    if not ispkg: continue # game support modules are packages
    # Equivalent of "from game import <modname>"
    try:
        module = __import__('game',globals(),locals(),[modname],-1)
    except ImportError:
        deprint(u'Error in game support module:', modname, traceback=True)
        continue
    submod = getattr(module,modname)
    if not hasattr(submod,'fsName') or not hasattr(submod,'exe'): continue
    _allGames[submod.fsName.lower()] = submod

但这有一个缺点,即所有的子包都是导入的,导入子包中的其他模块(例如constants.py等)相当于几兆字节的垃圾。所以我想用子模块有效的测试替换这段代码(它们 导入正常)。我想我应该以某种方式使用 eval - 但如何使用?或者我应该怎么做?

编辑: tldr;

我正在寻找与上述循环核心等效的函数:

    try:
        probaly_eval(game, modname) # fails iff `from game import modname` fails
        # but does _not_ import the module
    except: # I'd rather have a more specific error here but methinks not possible
        deprint(u'Error in game support module:', modname, traceback=True)
        continue

所以我想要一个明确的答案,如果 与导入语句完全等价 存在 vis 错误检查 - without 导入模块。这是我的问题,很多回答者和评论者回答了不同的问题。

如果你想编译文件而不导入它(在当前的解释器),你可以使用py_compile.compile作为:

>>> import py_compile

# valid python file
>>> py_compile.compile('/path/to/valid/python/file.py')

# invalid python file
>>> py_compile.compile('/path/to/in-valid/python/file.txt')
Sorry: TypeError: compile() expected string without null bytes

以上代码将错误写入 std.error。如果您想引发异常,则必须将 doraise 设置为 True(默认为 False)。因此,您的代码将是:

from py_compile import compile, PyCompileError

try:
    compile('/path/to/valid/python/file.py', doraise=True)
    valid_file = True
except PyCompileError:
    valid_file = False

根据 py_compile.compile's documents:

Compile a source file to byte-code and write out the byte-code cache file. The source code is loaded from the file named file. The byte-code is written to cfile, which defaults to file + 'c' ('o' if optimization is enabled in the current interpreter). If dfile is specified, it is used as the name of the source file in error messages instead of file. If doraise is true, a PyCompileError is raised when an error is encountered while compiling file. If doraise is false (the default), an error string is written to sys.stderr, but no exception is raised.

检查以确保未导入已编译的模块 (在当前解释器中):

>>> import py_compile, sys
>>> py_compile.compile('/path/to/main.py')

>>> print [key for key in locals().keys() if isinstance(locals()[key], type(sys)) and not key.startswith('__')]
['py_compile', 'sys']  # main not present

也许您正在寻找 py_compilecompileall 模块。
这里是文档:
https://docs.python.org/2/library/py_compile.html
https://docs.python.org/2/library/compileall.html#module-compileall

您可以将您想要的模块作为模块加载并在您的程序中调用它。
例如:

import py_compile

try:
    py_compile.compile(your_py_file, doraise=True)
    module_ok = True
except py_compile.PyCompileError:
    module_ok = False

你不能真正有效地做你想做的事。为了查看包是否 "valid",您需要 运行 它——而不仅仅是检查它是否存在——因为它可能有错误或未满足的依赖关系。

使用 pycompilecompileall 只会测试您是否可以编译 python 文件 ,而不是导入模块。两者区别很大

  1. 这种方法意味着您知道模块的实际 file-structure -- import foo 可以表示 /foo.py/foo/__init__.py
  2. 该方法不能保证模块在您的解释器的 python 路径中或者是您的解释器将加载的模块。如果您在 /site-packages/ 中有多个版本,或者 python 正在寻找模块的许多可能位置之一,事情就会变得棘手。
  3. 仅仅因为您的文件 "compiles" 并不意味着它会 "run"。作为一个包,它可能有未满足的依赖关系,甚至会引发错误。

假设这是您的 python 文件:

 from makebelieve import nothing
 raise ValueError("ABORT")

上面的代码可以编译,但是如果你导入它们...如果你没有安装 makebelieve 它会抛出一个 ImportError 并且如果你安装了它会抛出一个 ValueError 。

我的建议是:

  1. 导入包然后卸载模块。要卸载它们,只需遍历 sys.modules.keys()​​​ 中的内容。如果您担心加载的外部模块,您可以覆盖 import 来记录您的包加载的内容。这方面的一个例子是我写的 terrible 分析包:https://github.com/jvanasco/import_logger [我忘记了我从哪里得到覆盖导入的想法。也许 celery?] 正如一些人指出的那样,卸载模块完全取决于解释器——但几乎每个选项都有很多缺点。

  2. 使用子进程通过 popen 启动新的解释器。即popen('python', '-m', 'module_name')。如果你对每个需要的模块都这样做(每个解释器和导入的开销),这会有很多开销,但是你可以编写一个“.py”文件来导入你需要的一切,然后尝试 运行 .在任何一种情况下,您都必须分析输出——因为导入 "valid" 包可能会在执行期间导致可接受的错误。我不记得子进程是否继承了您的环境变量,但我相信是的。子进程是一个全新的操作系统 process/interpreter,因此模块将加载到 short-lived 进程的 memory.clarified 答案中。

我相信 imp.find_module 至少满足您的部分要求:https://docs.python.org/2/library/imp.html#imp.find_module

快速测试表明它不会触发导入:

>>> import imp
>>> import sys
>>> len(sys.modules)
47
>>> imp.find_module('email')
(None, 'C:\Python27\lib\email', ('', '', 5))
>>> len(sys.modules)
47
>>> import email
>>> len(sys.modules)
70

这是我的一些代码(尝试对模块进行分类)中的示例用法:https://github.com/asottile/aspy.refactor_imports/blob/2b9bf8bd2cf22ef114bcc2eb3e157b99825204e0/aspy/refactor_imports/classify.py#L38-L44

我们已经有一个custom importer(免责声明:我没有写那个代码我只是当前的维护者)load_module:

def load_module(self,fullname):
    if fullname in sys.modules:
        return sys.modules[fullname]
    else: # set to avoid reimporting recursively
        sys.modules[fullname] = imp.new_module(fullname)
    if isinstance(fullname,unicode):
        filename = fullname.replace(u'.',u'\')
        ext = u'.py'
        initfile = u'__init__'
    else:
        filename = fullname.replace('.','\')
        ext = '.py'
        initfile = '__init__'
    try:
        if os.path.exists(filename+ext):
            with open(filename+ext,'U') as fp:
                mod = imp.load_source(fullname,filename+ext,fp)
                sys.modules[fullname] = mod
                mod.__loader__ = self
        else:
            mod = sys.modules[fullname]
            mod.__loader__ = self
            mod.__file__ = os.path.join(os.getcwd(),filename)
            mod.__path__ = [filename]
            #init file
            initfile = os.path.join(filename,initfile+ext)
            if os.path.exists(initfile):
                with open(initfile,'U') as fp:
                    code = fp.read()
                exec compile(code, initfile, 'exec') in mod.__dict__
        return mod
    except Exception as e: # wrap in ImportError a la python2 - will keep
        # the original traceback even if import errors nest
        print 'fail', filename+ext
        raise ImportError, u'caused by ' + repr(e), sys.exc_info()[2]

所以我想我可以用可覆盖的方法替换访问 sys.modules 缓存的部分,这些方法将在我的覆盖中单独保留该缓存:

所以:

@@ -48,2 +55,2 @@ class UnicodeImporter(object):
-        if fullname in sys.modules:
-            return sys.modules[fullname]
+        if self._check_imported(fullname):
+            return self._get_imported(fullname)
@@ -51 +58 @@ class UnicodeImporter(object):
-            sys.modules[fullname] = imp.new_module(fullname)
+            self._add_to_imported(fullname, imp.new_module(fullname))
@@ -64 +71 @@ class UnicodeImporter(object):
-                    sys.modules[fullname] = mod
+                    self._add_to_imported(fullname, mod)
@@ -67 +74 @@ class UnicodeImporter(object):
-                mod = sys.modules[fullname]
+                mod = self._get_imported(fullname)

并定义:

class FakeUnicodeImporter(UnicodeImporter):

    _modules_to_discard = {}

    def _check_imported(self, fullname):
        return fullname in sys.modules or fullname in self._modules_to_discard

    def _get_imported(self, fullname):
        try:
            return sys.modules[fullname]
        except KeyError:
            return self._modules_to_discard[fullname]

    def _add_to_imported(self, fullname, mod):
        self._modules_to_discard[fullname] = mod

    @classmethod
    def cleanup(cls):
        cls._modules_to_discard.clear()

然后我在 sys.meta_path 中添加了导入器,一切顺利:

importer = sys.meta_path[0]
try:
    if not hasattr(sys,'frozen'):
        sys.meta_path = [fake_importer()]
    perform_the_imports() # see question
finally:
    fake_importer.cleanup()
    sys.meta_path = [importer]

对吗?错了!

Traceback (most recent call last):
  File "bash\bush.py", line 74, in __supportedGames
    module = __import__('game',globals(),locals(),[modname],-1)
  File "Wrye Bash Launcher.pyw", line 83, in load_module
    exec compile(code, initfile, 'exec') in mod.__dict__
  File "bash\game\game1\__init__.py", line 29, in <module>
    from .constants import *
ImportError: caused by SystemError("Parent module 'bash.game.game1' not loaded, cannot perform relative import",)

嗯?我目前正在导入同一个模块。那么答案可能在 import's docs

If the module is not found in the cache, then sys.meta_path is searched (the specification for sys.meta_path can be found in PEP 302).

这不完全是重点,但我 猜测 是语句 from .constants import * 查找 sys.modules 检查父模块是否存在,我看不到绕过它的方法(请注意,我们的自定义加载器正在使用模块的内置导入机制,mod.__loader__ = self 是在事后设置的)。

所以我更新了我的 FakeImporter 以使用 sys.modules 缓存,然后清理它。

class FakeUnicodeImporter(UnicodeImporter):

    _modules_to_discard = set()

    def _check_imported(self, fullname):
        return fullname in sys.modules or fullname in self._modules_to_discard

    def _add_to_imported(self, fullname, mod):
        super(FakeUnicodeImporter, self)._add_to_imported(fullname, mod)
        self._modules_to_discard.add(fullname)

    @classmethod
    def cleanup(cls):
        for m in cls._modules_to_discard: del sys.modules[m]

然而,这以一种新的方式 - 或者更确切地说是两种方式:

  • 对游戏/包的引用保存在 sys.modules 的 bash 顶级包实例中:

    bash\
      __init__.py
      the_code_in_question_is_here.py
      game\
        ...
    

    因为 game 被导入为 bash.game。该引用包含对所有 game1, game2,...、子包的引用,因此这些子包从未被垃圾收集

  • 对另一个模块 (brec) 的引用被同一个 bash 模块实例保存为 bash.brec。此引用在 game\game1 中作为 from .. import brec 导入,没有触发导入 ​​,以更新 SomeClass。但是,在另一个模块中,from ...brec import SomeClass did 形式的导入会触发导入,并且 另一个 brec 模块的实例在 sys.modules 中结束。该实例有一个未更新的 SomeClass 并因 AttributeError 而爆炸。

两者都是通过手动删除这些引用来修复的 - 所以 gc 收集了所有模块(75 中的 5 MB ram)并且 from .. import brec 确实触发了导入(这个 from ... import foo vs from ...foo import bar 需要一个问题)。

这个故事的寓意是它是可能的但是:

  • 包和子包只能相互引用
  • 应从顶级包属性中删除对外部 modules/packages 的所有引用
  • 应从顶级包属性中删除包引用本身

如果这听起来很复杂而且容易出错——至少现在我对相互依存关系及其危险有了更清晰的认识——是时候解决这个问题了。


这个 post 是由 Pydev 的调试器赞助的 - 我发现 gc 模块对于理解正在发生的事情非常有用 - 来自 here 的提示。当然有很多变量是调试器的和那些复杂的东西