使用pyximport重新加载模块?

7

我有一个Python程序,在运行之前需要加载大量数据。因此,我希望能够重新加载代码而不重新加载数据。使用常规的Python,importlib.reload 已经可以正常工作。下面是一个示例:

setup.py:

from distutils.core import setup
from distutils.extension import Extension
from Cython.Build import cythonize

extensions = [
    Extension("foo.bar", ["foo/bar.pyx"],
              language="c++",
              extra_compile_args=["-std=c++11"],
              extra_link_args=["-std=c++11"])
]
setup(
    name="system2",
    ext_modules=cythonize(extensions, compiler_directives={'language_level' : "3"}),
)

foo/bar.py

cpdef say_hello():
    print('Hello!')

runner.py:

import pyximport
pyximport.install(reload_support=True)

import foo.bar
import subprocess
from importlib import reload

if __name__ == '__main__':

    def reload_bar():
        p = subprocess.Popen('python setup.py build_ext --inplace',
                             shell=True,
                             cwd='<your directory>')
        p.wait()

        reload(foo.bar)
        foo.bar.say_hello()

但是这似乎不起作用。如果我编辑bar.pyx并运行reload_bar,我看不到我的更改。我还尝试了pyximport.build_module(),但没有成功-模块已重建但未重新加载。如果有区别的话,我正在运行一个“正常”的Python shell,而不是IPython。


你是如何尝试使用重新加载的模块的?一般来说(无论是否使用Cython),reload(x)不能基于x模块中的内容更新现有对象,并且不会替换任何对x的其他引用(例如,如果模块y也执行了import x,那么它不会自动更新)。您需要非常彻底地更新所有引用x的内容。 - DavidW
是的,我知道这个。通常我会从重新加载的包中重新创建一个对象并重新运行。我已经用更好的例子更新了原帖。 - slushi
你看到这个回答上的第二条评论了吗? - DavidW
眼力不错,但并没有帮上忙。我将会更新问题中的代码以反映代码的更改。 - slushi
我能和你开个聊天室吗,slushi?我现在没法试运行代码,所以有几件事情我想请你帮忙尝试一下,但我不想在评论区里发太多对话。 - Dillon Davis
@DillonDavis 我不太确定那是怎么工作的,因为我并不经常使用SO,但聊天是可以的。 - slushi
2个回答

6
我可以为Python 2.x轻松地找到解决方案,但这个过程在Python 3.x中则比较复杂。似乎Cython会缓存它从你的模块导入的共享对象(.so)文件,并且即使在运行时重建和删除旧文件后,它仍然会从旧的共享对象文件中导入。然而,这并不是必需的(当你import foo.bar时,它并不会创建一个)。因此,我们可以跳过这一步。
最大的问题是,即使使用了reload(foo.bar),Python仍然保留对旧模块的引用。正常的Python模块似乎能够正常工作,但与Cython相关的内容则不行。为了解决这个问题,我执行了两个语句来代替reload(foo.bar)
del sys.modules['foo.bar']
import foo.bar

这样做可以成功(虽然可能不够高效)重新加载Cython模块。唯一的问题在于,在Python 3.x中运行子进程会创建有问题的共享对象。因此,完全跳过这一步,让import foo.bar使用pyximporter模块发挥其魔力,并为您重新编译。我还在pyxinstall命令中添加了一个选项,以指定语言级别以匹配您在setup.py中指定的级别。
pyximport.install(reload_support=True, language_level=3)

总的来说:

runner.py

import sys
import pyximport
pyximport.install(reload_support=True, language_level=3)

import foo.bar

if __name__ == '__main__':
    def reload_bar():
        del sys.modules['foo.bar']
        import foo.bar

    foo.bar.say_hello()
    input("  press enter to proceed  ")
    reload_bar()
    foo.bar.say_hello()

另外两个文件保持不变

运行中:

Hello!
  press enter to proceed

foo/bar.pyx 中用 "Hello world!" 替换 "Hello!",然后按下 Enter 键。

Hello world!

无法在Linux上的Python 3.6.7,[GCC 8.2.0]中工作。 - j-i-l
@jojo 在 Python 3.5.22.7.12 上进行了测试,使用的是 linux 系统和 [GCC 5.4.0] 编译器,因此我猜你在使用更新版本时可能会有所不同。我建议加入以下代码以确保程序正常运行:from importlib import reload, invalidate_caches; invalidate_caches(); reload(foo.bar); - Dillon Davis
即使使用了那些方法调用和显式的 pyximport.build_module() 调用,在 Mac 上也无法在 3.6 上运行。而且我也不确定是否能够通过这种方法设置“使用 C++”选项。 - slushi
@slushi 我忘了提醒你,在运行之前应该删除.so目标文件,否则它会使用该文件并导致任何缓存问题。 - Dillon Davis
解决了!同时参考https://stackoverflow.com/questions/21938065/how-to-configure-pyximport-to-always-make-a-cpp-file,现在我可以重新加载我的C++代码了! - slushi

4
Cython扩展不是通常的Python模块,因此底层操作系统的行为会透过。本答案涉及Linux,但其他操作系统也存在类似的行为/问题(好吧,Windows甚至不允许您重新构建扩展)。Cython扩展是共享对象。在导入时,CPython通过ldopen打开此共享对象,并调用init-function,即Python3中的PyInit_<module_name>,其中包括注册扩展提供的功能/函数等内容。如果加载了共享对象,则无法卸载它,因为可能存在一些Python对象仍然存活,这些对象将具有指向原始共享对象功能的悬空指针而不是函数指针。例如,请参见此CPython-issue。另一个重要的事情是:当ldopen使用与已加载的共享对象相同的路径加载共享对象时,它将不会从磁盘上读取它,而只是重复使用已加载的版本,即使磁盘上有不同版本也是如此。
这就是我们方法的问题:只要生成的共享对象与旧对象具有相同的名称,您将无法在不重新启动解释器的情况下看到新功能。
你有哪些选择?
A: 使用pyximportreload_support=True 假设您的Cython (foo.pyx)模块如下所示:
def doit(): 
    print(42)
# called when loaded:
doit()

现在使用pyximport导入它:

>>> import pyximport
>>> pyximport.install(reload_support=True)
>>> import foo
42
>>> foo.doit()
42

foo.pyx已经被构建和加载(正如预期,我们可以看到它在加载时打印出42)。让我们来看一下foo文件:

>>> foo.__file__
'/home/XXX/.pyxbld/lib.linux-x86_64-3.6/foo.cpython-36m-x86_64-linux-gnu.so.reload1'

您可以看到与使用reload_support=False构建的情况相比,增加了reload1后缀。通过查看文件名,我们还验证了路径中没有其他错误加载的foo.so文件。

现在,让我们将foo.pyx中的42更改为21并重新加载文件:

>>> import importlib
>>> importlib.reload(foo)
21
>>> foo.doit()
42
>>> foo.__file__
'/home/XXX/.pyxbld/lib.linux-x86_64-3.6/foo.cpython-36m-x86_64-linux-gnu.so.reload2'

发生了什么?pyximport使用不同的前缀(reload2)构建了一个扩展并加载它。这是成功的,因为由于新前缀,新扩展的名称/路径不同,我们可以看到在加载时打印了21
然而,foo.doit()仍然是旧版本!如果我们查阅reload-documentation,我们会看到:
当执行reload()时: Python模块的代码被重新编译并且模块级别的代码被重新执行, 定义了一组新的对象,这些对象通过重用最初加载模块的加载器绑定到模块字典中的名称。 扩展模块的init函数不会第二次调用

init(即PyInit_<module_name>)不会为扩展程序执行(这也适用于Cython扩展),因此PyModuleDef_Initfoo-模块定义不会被调用,因此将卡在绑定到foo.doit的旧定义上。这种行为是合理的,因为对于某些扩展,不应该调用init函数两次。

要解决此问题,我们必须再次导入模块foo

>>> import foo
>>> foo.doit()
21

现在foo已经重新加载得尽善尽美了-这意味着可能仍然有旧对象正在使用。但我相信您知道自己在做什么。

B:每个版本更改扩展名

另一种策略可以是将模块foo.pyx构建为foo_prefix1.so,然后是foo_prefix2.so等等,并将其作为加载。

>>> import foo_perfixX as foo

这是IPython中%%cython魔法使用的策略,它使用Cython代码的sha1哈希值作为前缀。您可以使用imp.load_dynamic(或其实现与importlib的帮助)来模拟IPython的方法,因为imp已被弃用。
from importlib._bootstrap _load
def load_dynamic(name, path, file=None):
    """
    Load an extension module.
    """
    import importlib.machinery
    loader = importlib.machinery.ExtensionFileLoader(name, path)

    # Issue #24748: Skip the sys.modules check in _load_module_shim;
    # always load new extension
    spec = importlib.machinery.ModuleSpec(
        name=name, loader=loader, origin=path)
    return _load(spec)

现在,我们可以将so文件放入不同的文件夹中(或添加一些后缀),这样dlopen就会将它们视为与先前版本不同的文件,然后我们就可以使用它:

# first argument (name="foo") tells how the init-function 
# of the extension (i.e. `PyInit_<module_name>`) is called 
foo =  load_dynamic("foo", "1/foo.cpython-37m-x86_64-linux-gnu.so")
# now foo has new functionality:
foo = load_dynamic("foo", "2/foo.cpython-37m-x86_64-linux-gnu.so")

即使重新加载和特别是重新加载扩展有点hacky,出于原型设计的目的,我可能会选择pyximport解决方案...或使用IPython和%%cython魔法。


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