PAYATHON 2026

Python 经 PyInstaller 打包为 exe 后,如何运行批处理文件?

数据小许

PyInstaller 打包后的程序可以用 subprocess.Popen 启动批处理文件。目前的材料不足以将故障归因于 onefile 临时目录。代码中有几个问题值得先排查:batch_path 是否已定义、用 os.getcwd() 定位文件,以及在应用进程尚未退出时删除自身 exe。

如果第二段脚本用于更新当前运行的程序,还需要让批处理等旧 exe 可以删除后再执行重命名。关闭 tkinter 窗口不代表 Windows 已经释放了可执行文件。

先确认程序是否执行到了创建批处理的代码

第二段代码中有这一行:

create_and_write_file("test.txt", batch_path)

但所提供代码中,给 batch_path 赋值的语句被注释掉了:

#batch_path = (rf"{pth}\{BATCH_FILE}")

如果完整程序没有在其他位置定义 batch_path,调用会在参数求值时触发 NameError,甚至不会进入 create_and_write_file()。按照材料展示的顺序,这行代码位于 create_and_run_batch() 调用之前,足以解释为什么最新版本连 bat 都没有生成。

另外,create_and_write_file() 实际写入的是 C:\yyyy\output.txt,没有使用传入的 filename。如果目录不存在,open(..., 'w') 不会自动创建目录。这个调试函数写入的文件与 run_script.bat 也不是同一个文件。

提供的代码还缺少 Python 缩进。请以实际源文件为准检查:sys.exit(1) 应位于写入失败的 except 内;如果错误地放在异常处理之外,程序会在成功写入后直接退出。

使用实际 exe 所在目录,明确设置 cwd

os.getcwd() 返回进程的当前工作目录,不保证等于 exe 所在目录。通过快捷方式、命令行或其他程序启动时,当前工作目录可能不同。

对于放在实际 exe 旁边的更新文件,可以这样确定目录:

from pathlib import Path
import sys


def get_app_dir():
    if getattr(sys, "frozen", False):
        return Path(sys.executable).resolve().parent
    return Path(__file__).resolve().parent

这里的 .py 分支假设这段代码位于应用入口文件中。如果代码放在其他目录下的辅助模块里,应从入口显式传入应用目录。

onefile 解包目录适合定位随包资源。需要在程序退出后继续使用的更新脚本和更新文件,应放在明确的持久目录中。下面的示例假设应用安装在 Windows 本地目录,且当前用户有权限在该目录写入、删除和重命名文件。

修改批处理的删除和重命名逻辑

原脚本有两个独立问题:

  • set "targetFile=..." :: File to delete 中的 :: 不能作为通用的行尾注释,可能让赋值结果不符合预期。把注释放在单独一行。
  • ren 放在 if exist "%targetFile%" 内部,会导致旧文件不存在时也跳过重命名。

如果目标文件就是当前运行的 exe,del /f /q 仍可能因文件被占用而失败。/f 不能解除运行中可执行文件的占用。

下面的批处理先检查更新文件,再有限次数重试删除旧文件。删除失败时会保留更新文件和脚本,便于检查日志:

@echo off
setlocal

cd /d "%~dp0"
if errorlevel 1 exit /b 1

REM File to replace
set "targetFile=CSSI_QA_Validator_DailyData.exe"
REM Downloaded replacement
set "triggerFile=CSSI_QA_Validator_DailyData_UD.exe"
set "attempts=30"

if not exist "%triggerFile%" (
    echo ERROR: Update file not found.
    exit /b 1
)

:retry_delete
if not exist "%targetFile%" goto install

del /f /q "%targetFile%"
if not exist "%targetFile%" goto install

set /a attempts-=1
if %attempts% LEQ 0 (
    echo ERROR: Cannot delete the target file.
    exit /b 1
)

REM Brief delay before retrying; requires the Windows ping command.
ping -n 2 127.0.0.1 >nul
goto retry_delete

:install
ren "%triggerFile%" "%targetFile%"
if errorlevel 1 (
    echo ERROR: Cannot rename the update file.
    exit /b 1
)

echo Update completed.
endlocal
del "%~f0" >nul 2>&1

这个示例最多尝试删除 30 次,重试之间使用回环地址 ping 做短暂等待,不保证等待时长精确。如果失败原因是目录权限,等待不会解决问题。

创建脚本、启动 cmd.exe,并保存运行日志

将上面的批处理正文保存为 Python 字符串 batch_content,再使用下面的函数写入并启动:

import subprocess


def start_update(batch_content):
    app_dir = get_app_dir()
    batch_file = app_dir / "run_script.bat"
    log_file = app_dir / "update.log"

    with batch_file.open("w", encoding="utf-8", newline="\r\n") as file:
        file.write(batch_content)

    with log_file.open("ab") as log:
        process = subprocess.Popen(
            ["cmd.exe", "/d", "/c", str(batch_file)],
            cwd=str(app_dir),
            shell=False,
            stdout=log,
            stderr=subprocess.STDOUT,
        )

    return process

这里已经显式调用 cmd.exe,不需要再设置 shell=True。排查期间先不使用 CREATE_NO_WINDOW,同时查看 update.log 中的命令输出。

文件写入、工作目录不存在或进程创建失败都可能触发 OSError。原代码只捕获 subprocess.SubprocessError,不能覆盖这些情况。可以在调用处记录异常:

import logging

app_dir = get_app_dir()
logging.basicConfig(
    filename=str(app_dir / "launcher.log"),
    level=logging.INFO,
    encoding="utf-8",
)

try:
    start_update(batch_content)
except OSError:
    logging.exception("Failed to create or start the update script")
    raise

日志目录也必须可写。如果应用目录受权限限制,应选择可写的日志位置,并相应调整更新方案。

在 tkinter 主循环结束后启动更新,然后让应用退出

把更新调用放到主入口的 root.mainloop() 后面,并按实际业务条件决定是否执行:

root.mainloop()

if update_requested:
    start_update(batch_content)

# Allow the application to finish normally here.

其中 update_requested 需要由应用自己的更新流程设置。退出前还要结束会阻止进程终止的后台任务或非 daemon 线程,否则窗口关闭后 exe 仍可能被占用。

不要在当前应用里调用 process.wait() 等待更新脚本完成。批处理正在等待当前 exe 释放,而当前应用又等待批处理结束,会让更新持续失败,直到重试耗尽。

还要确认更新文件已经复制完成。第一段代码使用 Popen 启动复制后,立即打印了 File copied to: ...,这只能说明启动调用已经返回,不能证明复制成功。复制阶段可以使用 subprocess.run(..., check=True) 等待并检查命令退出码,再允许用户进入退出更新流程。这个等待适用于复制阶段,不适用于等待当前应用退出的替换阶段。

第一段批处理中的 finally: 也不是 Windows 批处理的异常清理语法,应删除。排查时应分别确认“bat 已写入”“cmd.exe 已启动”和“复制、删除、重命名成功”,通过异常信息和命令日志确定失败发生在哪一步。

备注:内容仅供参考。