pyinstaller 可执行文件的差异更新(修改嵌入式 PYZ-00.pyz)

differential update of pyinstaller executable (modify embedded PYZ-00.pyz)

我打算创建一个巨大的可执行目录并将其安装在某些设备上。

想象一下,后来我在我的 python 模块之一中发现了一个错误。 有没有办法transfer/copy只修改字节码,用新字节码替换原来的字节码

我想这样做的原因是,在我的上下文中带宽非常昂贵,我想远程修补代码。

示例:我有一个包含两个文件的项目: prog.py: (以下三行)

import mod1
if __name__ == "__main__":
    mod1.hello()

mod1.py: (以下两行)

def hello():
    print("hello old world")

现在我使用 PYTHONHASHSEED=2 pyinstaller prog.py 创建我的目录并将其复制到我的设备

现在我修改mod1.py:

def hello():
    print("hello new world")

然后我用 PYTHONHASHSEED=2 pyinstaller prog.py 重新编译 完整目录的大小约为 10M 文件dist/prog/prog大小约1M

使用 pyi-archive_viewer 我可以从我的可执行文件 dist/prog/prog 中提取 PYZ-00.pyzPYZ-00.pyz 中,我可以找到并提取仅使用 133 个字节的 mod1

现在,如果我将该文件复制到我的设备,我该如何更新旧文件 dist/prog/prog 这样,它就有了新的 PYZ-00.pyz:mod1 字节码。

替换一个特定文件(模块)后,我可以用什么代码分解,我可以用什么代码重新组装?

备选方案:将 pyc 文件移动到 zip 文件 启动性能并不是那么重要。我也可以使用替代解决方案,其中没有创建 PYZ 文件并将其添加到可执行文件中,但 dist 目录包含一个包含所有 .pyc 文件的 zip 文件

另一种选择:将 .pyc 文件复制到应用程序目录中 这将导致 __file__ 具有与 PYZ 模式中完全相同的值。性能方面可能不是那么好并且会创建大量文件,但如果增量更新至关重要,也许可以选择一种方法来处理它。

这是一个相当复杂的问题,但我认为这可能至少是您正在寻找的问题的一部分。

根据您的示例,我更改了 prog.py,因此当从源 运行ning 时它可以正常导入,但是当使用来自 pyc 文件的 pyinstaller 运行s 冻结时直接。

import sys

def import_pyc(name):
    import py_compile
    import types
    import marshal
    
    pyversion = f"{sys.version_info.major}{sys.version_info.minor}"
    filename = f"{name}.cpython-{pyversion}.pyc"
    
    with open(filename, "rb") as pyc_file:
        # pyc files have 16 bytes reserved at the start in python 3.7+
        # due to https://www.python.org/dev/peps/pep-0552/
        # may change again in the future
        pyc_file.seek(16) 
        code_obj = marshal.load(pyc_file)

    module = types.ModuleType(name)
    exec(code_obj, module.__dict__)

    globals()[name] = module

def import_py(name):
    import importlib
    
    globals()[name] = importlib.import_module("mod1")
    
def import2(name):
    if getattr(sys, "frozen", False):
        import_pyc(name)
    else:
        import_py(name)


import2("mod1")

if __name__ == "__main__":
    mod1.hello()

这在很大程度上基于精彩的回答 here

这意味着 mod.py 没有被 PyInstaller 打包,您必须将 mod1.cpython-38.pyc 作为数据文件包含在内。

一个方便的方法是使用命令 PyInstaller --add-data "__pycache__/*;." prog.py(如果您不使用 Windows,请将分号切换为冒号)。这会将 __pycache__ 文件夹中的所有内容,所有导入的模块,放入结尾的 dist/prog 文件夹中。请注意,如果您 运行 这多次,PyInstaller 会在 __pycache__ 中为主 python 文件夹放置一个 pyc,以便在后续的 运行 中捆绑。

根据您捆绑的方式和 运行 您的项目,您可能会 运行 遇到当前工作目录关闭的问题,这将导致 FileNotFound 当您尝试加载 pycs。我不能给你找到你想要的路径的灵丹妙药,因为这取决于你最终如何做事,但我通常用来找到应该是当前工作目录的绝对路径的方法是 os.path.dirname(sys.executable)os.path.dirname(os.path.abspath(__file__)).

此解决方案既不能 'patching' 一个 .PYZ 文件,也不能将所有 .pyc 文件放入一个 zip 文件。

但到目前为止,这是我找到的唯一可行的解​​决方案,适用于具有大量第三方依赖项的大型项目。

想法是删除所有(或 .PYZ 文件中的大部分文件)并将相应的 .pyc 文件复制到工作目录中。

随着时间的推移,我会加强和详细说明这个答案。我还在试验:

我通过修改规范文件来实现:

  • 确定spec文件所在目录MYDIR
  • 创建一个目录,MYDIR/srca.pure 中的所有文件复制到
  • 将所有文件从 a.pure 复制到 MYDIR/src。 (子目录与模块名称相对应。例如,模块 mypackage.mod.common 将存储在 MYDIR/src/mypackage/mod/common.py 中)
  • 遍历文件并将它们编译为 .pyc 文件,然后删除 .py 文件。
  • 创建一个 PYZ 文件,其中仅包含未复制的文件。 (在我的测试用例中,PYZ 中没有 .pyc 文件)
  • 用修改后的 PYZ
  • 创建 exe
  • 收集应收集的所有文件以及来自 MYDIR/src 的所有文件(例如 a.datas + Tree("src")

规范文件更改: 一开始

import os
MYDIR = os.path.realpath(SPECPATH)
sys.path.append(MYDIR)
import mypyinsthelpers  # allows to reuse the code in multiple projects

然后在(未修改的)a = Analysis(... 部分之后添加。

to_rmv_from_pyc = mypyinsthelpers.mk_copy_n_compile(a.pure, MYDIR)

# modified creation of pyz`
pyz = PYZ(a.pure - to_rmv_from_pyc, a.zipped_data,
             cipher=block_cipher)

我会在下面详细介绍功能mypyinsthelpers.mk_copy_n_compile

更改收集阶段:

而不是

coll = COLLECT(exe,
               a.binaries,
               a.zipfiles,
               a.datas,
...

我写:

coll = COLLECT(exe,
               a.binaries,
               a.zipfiles,
               a.datas + Tree("src"),
...

这里是mypyinsthelpers.mk_copy_n_compile()

的声明
import compileall
import os
import shutil
from pathlib import Path


def mk_copy_n_compile(toc, src_tree):
    """
    - copy source files to a destination directory
    - compile them as pyc
    - delete source
    """
    dst_base_path = os.path.join(src_tree, "src")
    to_rm = []
    # copy files to destination tree
    for entry in toc:
        modname, src, typ = entry
        assert typ == "PYMODULE"
        assert src.endswith(".py") or src.endswith(".pyw")
        # TODO: might add logic to skip some files (keep them in PYC)
        to_rm.append(entry)

        if src.endswith("__init__.py"):
            modname += ".__init__"

        m_split = modname.split(".")
        m_split[-1] += ".py"
        dst_dir = os.path.join(dst_base_path, *m_split[:-1])
        dst_path = os.path.join(dst_dir, m_split[-1])
        if not os.path.isdir(dst_dir):
            os.makedirs(dst_dir)
        print(entry[:2], dst_path)
        shutil.copy(src, dst_path)

    # now compile all files and rmv src
    top_tree = src_tree
    src_tree = os.path.join(src_tree, "src")
    curdir = os.getcwd()
    os.chdir(dst_base_path)
    for path in Path(dst_base_path).glob("**/*.py"):
        # TODO: might add code to keep some files as source
        compileall.compile_file(
            str(path.relative_to(dst_base_path)), quiet=1, legacy=True)
        path.unlink()
    os.chdir(curdir)
    return to_rm