一、问题背景

在 Windows 环境下,很多 Python Web 项目不仅包含 Python 后端代码,还可能依赖前端静态资源、配置文件、数据目录,以及额外的本地可执行程序。例如,项目中可能通过 Python 调用一个由 Go、C/C++ 或 Rust 编译出来的命令行程序,同时这个外部程序又依赖若干 DLL 动态链接库。

在开发环境中直接运行 Python 代码时,这些文件通常都位于项目根目录下,因此程序可以正常启动。但是当使用 PyInstaller 将项目打包成 EXE 后,如果没有显式配置资源收集规则,就很容易出现如下问题:

ModuleNotFoundError: No module named 'uvicorn'

或者:

未检测到外部可执行文件

或者:

ERROR: Error loading ASGI app. Could not import module "backend.main".

这些错误本质上并不完全相同,需要分别处理。


二、问题一:缺少 uvicorn 依赖

1. 错误现象

打包完成后运行 EXE,出现:

Traceback (most recent call last):
  File "run.py", line 18, in <module>
    import uvicorn
ModuleNotFoundError: No module named 'uvicorn'

2. 原因分析

这说明当前 Python 环境中没有安装 uvicorn,或者 requirements.txt 中没有声明该依赖。

对于 FastAPI 项目来说,常见依赖通常包括:

fastapi
pydantic
PyYAML
uvicorn[standard]

其中 uvicorn 是 ASGI 服务器,FastAPI 项目通常依赖它来启动 Web 服务。如果代码中存在:

import uvicorn

但环境中没有安装 uvicorn,打包后的 EXE 自然也无法运行。

3. 解决方法

先安装依赖:

python -m pip install -r requirements.txt

如果只是临时补装 uvicorn,也可以执行:

python -m pip install "uvicorn[standard]"

然后验证是否安装成功:

python -c "import uvicorn; print(uvicorn.__file__)"

如果能够输出类似下面的路径,说明依赖已经安装成功:

C:\Users\用户名\.conda\envs\环境名\lib\site-packages\uvicorn\__init__.py

三、问题二:外部可执行文件和 DLL 没有被打包进去

1. 错误现象

程序启动后提示:

未检测到外部可执行文件
请确认 xxx.exe 与相关 DLL 位于同一目录

同时检查打包目录时发现:

dir .\dist\MyApp\_internal\dist-win64

提示:

找不到路径

或者目标目录不存在。

2. 原因分析

PyInstaller 默认主要分析 Python 代码依赖。对于 .py 文件中通过 import 引入的 Python 模块,PyInstaller 通常可以自动识别。

但是下面这些资源,PyInstaller 通常不会自动收集:

外部 exe 文件
DLL 动态链接库
yaml/json 配置文件
前端静态资源目录
模板目录
运行时数据目录
输出目录

例如项目中有如下目录结构:

project-root/
├─ run.py
├─ backend/
├─ frontend/
├─ config.yaml
├─ dist-win64/
│  ├─ external-core.exe
│  ├─ libxxx.dll
│  └─ libyyy.dll
├─ data/
└─ outputs/

如果直接使用:

python -m PyInstaller -D -n MyApp .\run.py

PyInstaller 会自动生成一个 MyApp.spec 文件,但这个文件中通常是:

binaries=[]
datas=[]

这意味着外部 exe、DLL、配置文件、前端资源都没有被加入打包结果。

因此运行 EXE 时,Python 代码存在,但运行所需的外部资源不存在,程序就会报错。


四、核心原则:不要反复使用普通 PyInstaller 命令覆盖 spec 文件

第一次执行:

python -m PyInstaller -D -n MyApp .\run.py

会生成一个默认的 MyApp.spec 文件。

但是如果后续已经手动修改了 MyApp.spec,就不要再执行这条命令。因为它可能重新生成并覆盖原来的 spec 文件,导致之前添加的 datasbinarieshiddenimports 等配置全部丢失。

正确做法是:

python -m PyInstaller --clean --noconfirm .\MyApp.spec

也就是说,后续打包都应该基于修正后的 spec 文件,而不是重新从 run.py 生成默认 spec。


五、推荐的 spec 文件写法

下面给出一个通用的 MyApp.spec 示例,用于打包一个 FastAPI 项目,同时包含外部可执行文件、DLL、配置文件、前端资源和数据目录。

# -*- mode: python ; coding: utf-8 -*-

import os
from PyInstaller.utils.hooks import collect_submodules

block_cipher = None

ROOT = os.path.abspath(os.path.dirname(__file__))

datas = []
binaries = []
hiddenimports = []

# 收集后端动态导入模块
hiddenimports += collect_submodules("backend")

# 收集配置文件
config_file = os.path.join(ROOT, "config.yaml")
if os.path.exists(config_file):
    datas.append((config_file, "."))

# 收集前端静态资源
frontend_dir = os.path.join(ROOT, "frontend")
if os.path.isdir(frontend_dir):
    datas.append((frontend_dir, "frontend"))

# 收集数据目录
data_dir = os.path.join(ROOT, "data")
if os.path.isdir(data_dir):
    datas.append((data_dir, "data"))

# 收集输出目录
outputs_dir = os.path.join(ROOT, "outputs")
if os.path.isdir(outputs_dir):
    datas.append((outputs_dir, "outputs"))

# 收集外部 exe 和 DLL
native_dir = os.path.join(ROOT, "dist-win64")
if os.path.isdir(native_dir):
    binaries.append((os.path.join(native_dir, "external-core.exe"), "dist-win64"))

    for dll_name in [
        "libxxx.dll",
        "libyyy.dll",
    ]:
        dll_path = os.path.join(native_dir, dll_name)
        if os.path.exists(dll_path):
            binaries.append((dll_path, "dist-win64"))

a = Analysis(
    ["run.py"],
    pathex=[ROOT],
    binaries=binaries,
    datas=datas,
    hiddenimports=hiddenimports,
    hookspath=[],
    hooksconfig={},
    runtime_hooks=[],
    excludes=[],
    noarchive=False,
    optimize=0,
)

pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)

exe = EXE(
    pyz,
    a.scripts,
    [],
    exclude_binaries=True,
    name="MyApp",
    debug=False,
    bootloader_ignore_signals=False,
    strip=False,
    upx=True,
    console=True,
)

coll = COLLECT(
    exe,
    a.binaries,
    a.datas,
    strip=False,
    upx=True,
    upx_exclude=[],
    name="MyApp",
)

需要注意,里面的文件名要根据自己的项目实际情况修改。例如:

"external-core.exe"
"libxxx.dll"
"libyyy.dll"

应替换为项目实际使用的外部可执行文件和 DLL 名称。


六、PyInstaller 6 的 _internal 目录问题

PyInstaller 6 在 onedir 模式下,打包后的目录通常类似:

dist/
└─ MyApp/
   ├─ MyApp.exe
   └─ _internal/
      ├─ backend/
      ├─ frontend/
      ├─ config.yaml
      ├─ dist-win64/
      │  ├─ external-core.exe
      │  ├─ libxxx.dll
      │  └─ libyyy.dll
      └─ ...

因此程序运行时不能简单假设资源位于:

dist/MyApp/

很多资源实际位于:

dist/MyApp/_internal/

所以代码中应该根据 PyInstaller 的运行环境动态获取资源根目录。


七、推荐的资源路径获取方式

可以在 run.py 或公共工具模块中写一个函数:

from pathlib import Path
import sys

def resource_root() -> Path:
    if getattr(sys, "frozen", False):
        return Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent))
    return Path(__file__).resolve().parent

这个函数的含义是:

开发环境中,资源根目录是源码所在目录。

打包环境中,资源根目录是 PyInstaller 解包或收集资源的位置。

onedir 模式下,通常对应:

dist/MyApp/_internal

之后查找资源时就可以写成:

ROOT = resource_root()

native_exe = ROOT / "dist-win64" / "external-core.exe"
config_file = ROOT / "config.yaml"
frontend_dir = ROOT / "frontend"

这样同一套代码可以同时兼容开发环境和打包环境。


八、问题三:ASGI app 动态导入失败

1. 错误现象

运行 EXE 后,前面的初始化逻辑都正常,但是 uvicorn 启动失败:

ERROR: Error loading ASGI app. Could not import module "backend.main".

2. 原因分析

很多 FastAPI 项目会这样启动:

uvicorn.run("backend.main:app", host="127.0.0.1", port=8000)

这在源码环境中通常没有问题。

但是对 PyInstaller 来说,"backend.main:app" 是字符串形式的动态导入。PyInstaller 静态分析 Python 依赖时,不一定能准确知道应该把 backend.main 及其子模块全部打包进去。

因此打包后运行时,uvicorn 再去根据字符串导入:

backend.main

就可能失败。

3. 解决方法一:在 run.py 中显式导入 app

推荐将启动方式改为:

import uvicorn
from backend.main import app

def main():
    uvicorn.run(
        app,
        host="127.0.0.1",
        port=8000,
        reload=False,
    )

if __name__ == "__main__":
    main()

也就是说,不再让 uvicorn 通过字符串导入:

"backend.main:app"

而是在 Python 代码中显式导入:

from backend.main import app

这样 PyInstaller 更容易识别依赖关系。

4. 解决方法二:在 spec 中添加 hiddenimports

同时在 MyApp.spec 中加入:

from PyInstaller.utils.hooks import collect_submodules

hiddenimports = []
hiddenimports += collect_submodules("backend")

这样可以强制收集 backend 包下的所有子模块,避免动态导入失败。


九、run.py 的推荐写法

下面是一个更适合 PyInstaller 打包的 run.py 示例:

from pathlib import Path
import sys
import subprocess
import uvicorn

from backend.main import app


def resource_root() -> Path:
    if getattr(sys, "frozen", False):
        return Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent))
    return Path(__file__).resolve().parent


def check_native_runtime() -> None:
    root = resource_root()
    native_dir = root / "dist-win64"
    native_exe = native_dir / "external-core.exe"

    print("=" * 72)
    print("Python Web Application")
    print("=" * 72)
    print(f"[路径] 运行资源目录:{root}")

    if not native_exe.exists():
        print("[异常] 未检测到外部可执行文件。")
        print(f"[异常] 期望路径:{native_exe}")
        raise FileNotFoundError(str(native_exe))

    print(f"[正常] 已检测到外部可执行文件:{native_exe}")


def main() -> None:
    check_native_runtime()

    print("[启动] 服务地址:http://127.0.0.1:8000")
    print("[退出] 按 Ctrl+C 停止服务")
    print("=" * 72)

    uvicorn.run(
        app,
        host="127.0.0.1",
        port=8000,
        reload=False,
    )


if __name__ == "__main__":
    main()

这里的关键点有三个。

第一,显式导入:

from backend.main import app

第二,不使用 reload=True。打包后的 EXE 不适合使用热重载。

第三,不使用:

sys.executable -m uvicorn ...

因为在打包环境中,sys.executable 指向的是当前 EXE 本身,而不是开发环境中的 python.exe。如果继续使用 sys.executable -m uvicorn,可能导致模块查找异常,甚至出现递归启动问题。


十、完整打包流程

1. 安装依赖

python -m pip install -r requirements.txt

或者至少安装:

python -m pip install fastapi pydantic PyYAML "uvicorn[standard]"

2. 验证 uvicorn

python -c "import uvicorn; print(uvicorn.__file__)"

3. 使用 spec 文件打包

不要使用:

python -m PyInstaller -D -n MyApp .\run.py

应该使用:

python -m PyInstaller --clean --noconfirm .\MyApp.spec

4. 检查外部资源是否被打包

dir .\dist\MyApp\_internal\dist-win64

正常情况下应该能看到:

external-core.exe
libxxx.dll
libyyy.dll

5. 运行程序

.\dist\MyApp\MyApp.exe

6. 打开浏览器访问

http://127.0.0.1:8000

十一、常见错误与对应解决方案

错误现象 原因 解决方法
ModuleNotFoundError: No module named 'uvicorn' 当前环境未安装 uvicorn 执行 python -m pip install "uvicorn[standard]"
_internal\dist-win64 不存在 spec 中没有配置 binaries/datas 修改 spec,将外部 exe 和 DLL 加入 binaries
外部 exe 检测失败 资源路径写死,未兼容 PyInstaller 使用 sys._MEIPASSPath(sys.executable).parent 获取资源根目录
Could not import module "backend.main" uvicorn 字符串动态导入失败 改为 from backend.main import app,并在 spec 中添加 hiddenimports
修改 spec 后仍然无效 又执行了普通 PyInstaller 命令覆盖 spec 后续只使用 python -m PyInstaller --clean --noconfirm .\MyApp.spec
打包后热重载异常 EXE 环境不适合 reload=True 设置 reload=False

十二、总结

使用 PyInstaller 打包 Python Web 项目时,不能只关注 Python 代码本身。一个完整可运行的 EXE 通常还需要同时处理:

  1. Python 依赖是否安装完整;
  2. FastAPI、Uvicorn 等动态导入模块是否被正确收集;
  3. 外部 exe、DLL、配置文件、前端静态资源是否被加入 spec;
  4. 打包后的资源路径是否兼容 _internal 目录;
  5. 启动方式是否适合 EXE 环境。

核心原则是:普通命令只适合生成初始 spec 文件,正式打包应依赖手工维护后的 spec 文件。

推荐流程是:

python -m pip install -r requirements.txt
python -c "import uvicorn; print(uvicorn.__file__)"
python -m PyInstaller --clean --noconfirm .\MyApp.spec
dir .\dist\MyApp\_internal\dist-win64
.\dist\MyApp\MyApp.exe

只要依赖、资源收集、动态导入和运行路径这四个问题处理好,FastAPI 项目即使包含外部本地可执行文件和 DLL,也可以稳定打包成 Windows 可运行程序。

更多推荐