Nuitka编译包入口文件

Nuitka编译包入口文件

引子

在构建一个 Python 项目或服务时,我们通常会把功能划分为多个模块和包。项目中有些 .py 文件只是作为工具模块存在,提供函数、类或配置,供其他模块调用;另一些 .py 文件则扮演“主程序”的角色,比如用于启动服务、运行主逻辑,典型的例子是创建一个 Flask 应用的入口文件。但在通过 Nuitka 打包运行入口文件的时候,很可能会出现找不到模块等情况。这和 Python 的模块导入机制有关,需要对模块加载、主模块判断等有一些基本的理解。

模块及相关变量

Python 模块就是一个 .py 文件。

与模块紧密相关的有三个变量:__spec____name____package__

🔹 __spec__ 表示模块的“加载规范”(specification),它的值是一个 ModuleSpec 对象,来源于 Python 的模块导入机制(PEP 451)。这个对象记录了模块是如何被找到、加载和初始化的,比如模块的完整名字、源文件路径、使用的加载器(loader)。在模块被 import 加载时,Python 会先构造 __spec__,然后再根据它创建模块对象并执行模块代码。在正常导入的模块中,__spec__ 会被自动设置;但如果模块是直接作为脚本直接运行,那么 __spec__ 的值一般是 None__name____package__ 的值通常也是从 __spec__ 中推导而来,分别对应 __spec__.name__spec__.parent

🔹 __name__ 表示模块的名字。它的值取决于模块的加载方式。

  • 如果模块是被当作脚本直接执行(例如使用 python script.py),其 __name__ 值就是 "__main__"
  • 如果模块是通过 import 语句导入的,那么 __name__ 就是模块的完整名称,如 "mypkg.utils"

这个变量通常用于判断模块是被“当作主程序运行”,还是“被其他模块导入”:

1
2
if __name__ == "__main__":
main()

这种写法是 Python 项目的标准入口检查模式。

🔹 __package__ 表示模块所属的包名,Python 用它来确定相对导入的起点(也就是 from . import xxxfrom ..subpackage import xxx 这样的导入是否有效)。

  • 如果模块是通过 import 导入的,__package__ 会被自动设置为当前模块所属的包;
  • 如果模块是直接作为脚本运行的主模块(即 __name__ == "__main__"),通常 __package__None

如果 __package__ 的值为空或错误,使用相对导入时就会出现错误。因此主模块中通常不建议使用相对导入,而应使用绝对导入(例如 from mypkg.helpers import ...)。

1
2
# 相对导入必须在正确的包结构和 __package__ 情况下才可用
from .helpers import do_something
变量 含义 常见值 来源
__name__ 当前模块的名字 '__main__' / 'mypkg.mod' 与运行方式直接相关
__package__ 当前模块所属包 'mypkg' / None 通常来自 __spec__.parent
__spec__ 模块加载说明书(ModuleSpec) ModuleSpec(...) / None Python 导入机制创建

python 和 python -m

Python 提供了两种常见的模块执行方式:

  1. 通过 python script.py 直接运行某个脚本;
  2. 通过 python -m package.module 把模块当作包的一部分运行。

以一个简单的示例说明这两种执行方式的差异:

1
2
3
4
5
myproject/
├── mypkg/
│ ├── __init__.py
│ ├── main.py # 主程序(入口)
│ └── helper.py # 工具模块

其中 main.py 内容如下:

1
2
3
4
5
6
7
8
# mypkg/main.py
from . import helper # 相对导入

def main():
print("Running main")

if __name__ == "__main__":
main()

当直接运行主程序文件时:

1
python mypkg/main.py

会得到如下错误:

1
ImportError: attempted relative import with no known parent package

这主要是由于 Python 会将 main.py 作为脚本直接运行,__name__ = "__main__"。但 __package__ = None,使得相对导入 from . import helper 失败,因为 Python 无法判断“.”指代的是哪个包。

通过在 main.py 中添加调试语句可以看到:

1
2
print("__name__:", __name__)
print("__package__:", __package__)

输出结果:

1
2
__name__: __main__
__package__: None

当使用 python -m mypkg.main 执行时:

1
python -m mypkg.main

程序正常运行,无任何报错。这是因为此时 Python 会将 mypkg.main 当作包中的模块运行,自动设置:

  • __name__ = "mypkg.main"
  • __package__ = "mypkg"

这样 .helper 这个相对导入才有了“上下文”,能够被正确识别和解析。输出结果为:

1
2
3
__name__: mypkg.main
__package__: mypkg
Running main

总之,相对导入依赖 __package__ 正确设置,而只有 python -m 能保证在入口模块中自动设置它。

Nuitka

我们编写了一些 Python 代码,希望它在没有安装 Python 的电脑上也可以运行。这个时候一般有两套主流的解决方案,一个是 PyInstaller、另一个是 Nuitka。PyInstaller 实际上是一个“打包器”,它会把 Python 环境和我们的代码打包到一起,拼凑成一个可执行文件。然而PyInstaller 打包出来的可执行文件通常很大,同时由于 PyInstaller 只起到打包的作用,它非常容易被解包导致源代码泄漏。而 Nuitka 是一个将 Python 代码编译为 C/C++ 再编译成原生可执行文件的工具。与 PyInstaller 这类“打包器”不同,Nuitka 实际上是一个编译器,它会对 Python 源码进行编译优化,生成高性能的二进制程序。Nuitka 生成的可执行文件体积会小很多,而且它可以对源代码起到很好的保护作用。

基本参数

安装了 Nuitka 包后,可以使用 nuitka main.py 或者 python -m nuitka main.py 这样的方式进行 Python 代码编译。其中,最常用的参数就是 --standalone,它告诉 Nuitka 打包为独立可运行目录,包含 Python 解释器及依赖库。如果不设置 --standalone,Nuitka 只会编译传入的主模块,只是编译了个“裸主程序”,不会包含任何依赖库、Python 解释器。编译出来的可执行文件只能在当前环境下运行,在别的机器上复制它无法运行。设置了 --standalone 选项后,Nuitka 会将主程序 + 所有依赖模块 + Python 运行时库(解释器)+ 所需 DLL,全部打包到一个目录中(如 myscript.dist/)。打包后的结果会成为真正可分发的应用目录,在没有 Python 环境的机器上也能直接运行。

对于我们的简单的示例,只需要执行:

1
nuitka --standalone mypkg/main.py

就可以完成打包了。另一个在实际使用中最常用的选项是

1
--nofollow-import-to=xxx,yyy

它用于显式排除一些模块或包不被编译进最终的可执行程序中。这个选项在处理一些大型第三方库(如 FlaskNumPyPandas 等)时很有用。因为这些库通常依赖链庞大、包含大量 C 扩展模块。完整编译它们耗时极长,而且很容易因为平台兼容性或复杂依赖而出错。同时,这些库本身就是开源的,我们也不需要对它们进行源码保护或加速优化。因此,在实际项目中,通常对这些第三方库使用 --nofollow-import-to 排除掉。在使用 --nofollow-import-to 排除模块后,Nuitka 不会将这些模块编译、复制进 .dist/ 目录,生成的可执行程序就会在启动时报错找不到这些模块,比如:

1
ModuleNotFoundError: No module named 'flask'

因为 Python 在导入模块时,首先会查找当前执行文件所在目录。因此我们只需手动将被排除的依赖项补齐到可执行文件所在的目录结构中即可,比如将 site-packages/flask/ 整个拷贝到 main.dist/ 中,对于其他所依赖的包或者模块同样的逐一复制。对于 C 扩展模块或动态链接库,由于系统还是会首先查找“当前可执行文件所在目录”,因此把 .dll.so 动态库文件也一并复制到 .dist/ 目录,可执行文件就可以正常加载了。

还有另外三个长得比较像的三个参数:

参数 用途 控制内容 常用场景 是否需要指定路径
--include-package=xxx 显式包含整个 Python 包 .py 文件(代码) 确保 Nuitka 不漏掉包(递归导入或未显式使用) 否(包名)
--include-package-data=xxx 包含指定包中的数据文件 .py 文件,如 .json.yaml.ini 如果包中含有资源数据但 Nuitka 没自动识别 否(包名)
--include-data-dir=src=dst 显式复制一个目录到输出中 任意文件/文件夹,包括包、资源、混合结构 比如 config/、模型目录、静态资源目录等 ✅ 是(路径)

--include-package-data 用于保证对应 Python 包中的比如 data/ 中的 .json 和图片等也能在打包后使用;--include-data-dir=src=dst 用于复制某些独立资源目录等。比如,

1
--include-data-dir=config=config

表示将本地的 config/ 整个复制到打包输出目录中的 config/ 下。Nuitka 还有很多有意思的选项,比如:

  • 🧩 嵌入图标

  • 📝 设置文件版本、产品描述、公司名

  • 🎯 修改可执行文件属性(显示在资源管理器的“属性”窗口中)

  • ❌ 禁用终端窗口(适合 GUI 程序)

可以让编译出来的可执行文件更好看一些。

入口程序打包

在使用 Nuitka 编译入口文件时,如果把一个包内的模块(如 mypkg/main.py)作为编译入口,而其中又使用了相对导入时,打包后的程序就会出现导入错误的问题:

1
ImportError: attempted relative import with no known parent package

这是因为 Nuitka 编译出来的可执行文件的运行方式和前文所说的 python script.py 类似:Nuitka 会将传入的 main.py 视作顶层模块,编译后,它不再处于原有包结构 mypkg 下。这会导致 __package__None,相对导入失败,同样地,__spec__ 也不会被自动设置。

因此,需要把入口文件中的相对导入:

1
from . import helper

改为绝对导入:

1
from mypkg import helper