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 已启动”和“复制、删除、重命名成功”,通过异常信息和命令日志确定失败发生在哪一步。
备注:内容仅供参考。
