解决 Streamlit 应用打包 EXE 后本地文件读写问题
2025-02-28 10:49:41
Streamlit 应用打包成可执行文件(.exe)后本地读写问题
我把一个 Streamlit 应用通过 npm 和 electron-builder 打包成了一个 .exe 文件。应用本身的 Streamlit 组件,比如图表、按钮、过滤器等,都能正常工作。但我想通过这个打包后的程序在本地进行文件读写(例如保存 matplotlib 生成的图片),却遇到了问题。
我原本尝试使用 os.getcwd()
来获取当前工作目录, 并在此基础上创建新的文件夹和文件, 但这个方法在打包后的程序里行不通。 看起来打包后的应用是在某种特殊的“沙盒”环境里运行,而不是直接在 Windows 系统层面运行。
下面来深入研究这个问题并搞定它!
一、问题原因分析
这个问题的根源在于打包过程和运行环境的改变。
-
打包后的执行环境: 使用 Electron 和 stlite 打包 Streamlit 应用,本质上是创建了一个独立的运行环境。 这个环境包含了一个迷你的浏览器(Chromium 内核)和一个 Node.js 环境来运行你的 Python 代码。 这就意味着你的应用不再直接与宿主操作系统(这里是 Windows)打交道。
-
os.getcwd()
的局限性:os.getcwd()
返回的是 当前工作目录。 在开发环境中(直接运行 Streamlit),这个目录通常是你启动 Streamlit 服务的目录。 但在打包后的应用中,这个“当前工作目录”就变成了 Electron 应用内部的某个目录,而不是你期望的用户桌面或者可执行文件所在的目录。 -
权限问题(潜在): 即使你设法获取到了正确的路径, 写入文件时还可能会遇到权限问题, 特别是写入系统目录时。
二、解决方案
针对上面的分析,以下是几种可行的解决方案。
1. 使用 __dirname
(Electron 环境)
既然程序在Electron里,就用它的方式获取程序所在的目录. __dirname
变量总是指向当前执行脚本所在目录的绝对路径。但要注意的是,在 Electron 环境里,如果 main.js
在其他子文件夹,例如build/electron/main.js
,那么通过__dirname
直接拼路径也会出现问题。
-
原理: 在 Node.js 环境中,
__dirname
提供了一种可靠的方式来获取当前执行脚本所在的目录。 -
实现:
结合path.join 解决以上提到的问题// 在你的 package.json 中, "main" 字段指向了 "./build/electron/main.js"。 // 修改Electron主进程的代码,即在build/electron/main.js里面 const { app, BrowserWindow } = require('electron'); const path = require('path'); // 保持和stlite desktop 其他变量声明的风格保持一致。 const appPath = app.getAppPath(); //新增的,获得的是stlite的应用根目录 let mainWindow; //原先是 module.exports = async function createWindow() async function createWindow(){ mainWindow = new BrowserWindow({ width: 800, height: 600, webPreferences: { nodeIntegration: false, // 为了安全,禁用 Node 集成,因为前端不需要 contextIsolation: true, // 启用上下文隔离 preload: path.join(__dirname, 'preload.js') , // 使用预加载脚本 }, }); const url = "http://localhost:8080/"; //Streamlit端口 mainWindow.loadURL(url); mainWindow.on('closed', () => { mainWindow = null; }); // 在主进程中添加以下代码来处理与渲染进程的通信: const { ipcMain } = require('electron'); ipcMain.handle('get-base-path', async () => { // 如果打包后的程序在应用程序的根目录下(和package.json一个层级),则不需要此纠正 const correctionPath = path.join(appPath, "build", "electron") //和main.js 保持一个层级. return correctionPath; }); } // app.on('ready', createWindow); 保持和stlite desktop 一致。 app.whenReady().then(() => { createWindow() app.on('activate', function () { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) }) //所有窗口关闭 app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit(); } });
//build/electron/preload.js // 添加一个预加载脚本 (preload.js) 来安全地暴露 Node.js 功能到渲染进程: const { contextBridge, ipcRenderer } = require('electron') contextBridge.exposeInMainWorld('electronAPI', { getBasePath: () => ipcRenderer.invoke('get-base-path') })
然后在Streamlit (Python) 代码中使用它:
import streamlit as st import os import asyncio async def get_electron_base_path(): try: base_path = await st.components.v1.html( """ <script> async function getBasePath() { const basePath = await window.electronAPI.getBasePath(); const div = window.parent.document.createElement('div'); div.id = 'base-path-value'; div.innerText = basePath; div.style.display = 'none'; window.parent.document.body.appendChild(div); //发送给streamlit const event = new CustomEvent("basePathReceived", {detail: basePath}); window.parent.document.dispatchEvent(event); } getBasePath(); </script> """, height=0, ) # 使用 CustomEvent 来监听来自 JavaScript 的事件,获取basePath的值。 event_result = st.session_state.get('basePath_js_result',None) if event_result is None: # 仅当还没有值时监听 base_path_value = None event_result = st.experimental_get_query_params() # 在streamlit add_script 注册一个callback函数 st.experimental_set_query_params() #clear js_code_base = f""" <script> //在父窗口中创建一个隐藏的元素来承载变量 function listenForBasePath () {{ console.log("listen event....") const base_path_event_listener = window.parent.document.addEventListener('basePathReceived', (event) => {{ var base_hidden = window.parent.document.getElementById("basePathValue"); if (!base_hidden) {{ base_hidden = window.parent.document.createElement("input"); base_hidden.type = "hidden"; base_hidden.id = "basePathValue"; window.parent.document.body.appendChild(base_hidden); }} base_hidden.value = event.detail;//接收 console.log("basePathValue get value: "+ base_hidden.value) // callback to Streamlit var streamlitCallback = window.parent.window["_streamlit_JsToStFunctions"]; if (streamlitCallback && streamlitCallback["_stMarkdown"]){{ streamlitCallback["_stMarkdown"]("basePathValue") }}; }}); }} listenForBasePath(); </script> """ # 用st.markdown调用注册的_stMarkdown callback 函数。 st.components.v1.html(js_code_base, height=0) st.markdown( """ <script> //给streamlit的callback调用的函数注册 window._streamlit_JsToStFunctions = window._streamlit_JsToStFunctions || {}; window._streamlit_JsToStFunctions["_stMarkdown"] = (id) => { //通过stCallBackValue callback传递到streamlit. var base_path_hidden_ele = window.parent.document.getElementById("basePathValue"); var encoded_id = encodeURIComponent(id); var encoded_value = encodeURIComponent(base_path_hidden_ele.value); // replace the url, and trigger rerunning const url = new URL(window.parent.location.href) url.searchParams.set("stCallBackKey", encoded_id) url.searchParams.set("stCallBackValue", encoded_value) window.parent.location.replace(url.toString()) }; </script> """,unsafe_allow_html=True) # 把query param解析的value记录在 session_state里面 if 'stCallBackKey' in event_result and event_result['stCallBackKey'][0] == 'basePathValue': base_path = event_result['stCallBackValue'][0] if st.session_state.get('basePath_js_result',None) != base_path: st.session_state['basePath_js_result'] = base_path #存储basePath_js_result在 session_state中。 #如果有改变,触发rerun if event_result and (st.session_state['basePath_js_result'] is None or (st.session_state.get('basePath_last') is not None and st.session_state['basePath_last'] != st.session_state['basePath_js_result'])): st.session_state['basePath_last'] = st.session_state['basePath_js_result'] #设置一个不一样的 st.experimental_rerun() if st.session_state.get('basePath_js_result',None) is not None: st.write("base path:", st.session_state['basePath_js_result'] ) #st.write('wait result...', st.session_state['basePath_js_result']) return st.session_state.get('basePath_js_result',None) except Exception as e: st.error(f"获取Electron 路径发生错误: {e}") return None with st.form(key='create_prints'): submit_button = st.form_submit_button(label='创建可打印文件') if submit_button: base_path_e = asyncio.run(get_electron_base_path()) if base_path_e: base_path = os.path.join(base_path_e, "output" ) st.write("文件保存到:", base_path) os.makedirs(base_path, exist_ok=True) # ... 剩下的文件操作 (使用 base_path) ... else: st.write('获取程序的base path失败!')
-
进阶技巧 : 可以通过修改
package.json
的build.directories.output
属性, 来指定输出目录, 让生成的文件放在你更方便管理的地方。 -
安全建议: 由于 Electron 应用有访问本地文件系统的能力, 一定要注意防范潜在的安全风险, 特别是在处理用户输入或者从网络获取数据时, 要做好输入校验和安全检查。
2. 使用系统对话框 (更推荐)
为了更好地兼容性,以及方便用户选择,更推荐的方法是直接弹出系统的文件选择/保存对话框, 让用户自己选择保存的位置。这种方式最直接、最符合用户习惯,也能避免路径和权限的问题。
-
原理: 利用HTML5 的文件输入框 (
<input type="file">
) , 并通过 Streamlit 的组件机制来触发和获取用户选择的路径。 -
实现:
这个实现,在npm install里,不需要特别依赖。import streamlit as st import base64 import time import os # 利用st.components.v1.html构建JavaScript函数. def get_save_path_with_dialog(): get_save_path_html = """ <script> async function getSavePath() { // 创建隐藏文件下载锚链接<a> const a = document.createElement('a'); a.style.display = 'none'; document.body.appendChild(a); // 弹出一个另存为的框,让用户决定文件放在哪里 var blob = new Blob(["please ignore"],{type: "text/plain"}); //内容不重要 const url = window.URL.createObjectURL(blob); a.href = url; a.download = 'fake.txt'; //随便给一个名字,最后不会生成 a.click(); //模拟click行为. //返回input box. return new Promise(resolve => { // 新建input box var input_ele = window.parent.document.getElementById("savePathInput"); if (!input_ele) { input_ele = document.createElement("INPUT"); input_ele.setAttribute("type", "file"); input_ele.setAttribute("id", "savePathInput"); input_ele.style.display = "none"; // 隐藏input box. window.parent.document.body.appendChild(input_ele); //添加到body. } //增加onchange 监听. 当有动件夹变动时触发。 const base_path_event_listener = input_ele.addEventListener('change', (event) => { //从e.target获得input的 FileList const files = event.target.files; if (files && files.length > 0) { const file = files[0]; //只用用户上传第一个. // 从file获取路径+文件信息. resolve(file.webkitRelativePath.replace("/fake.txt","")); } else { resolve(""); //点了取消 } }); input_ele.click(); //模拟点击click 行为. }); } //把最终path, 发给streamlit. async function trigger_saving(){ save_path = await getSavePath() const div = window.parent.document.createElement('div'); div.id = 'save-path-value'; div.innerText = save_path; //把值放在一个隐藏div div.style.display = 'none'; //设置隐藏 window.parent.document.body.appendChild(div); //发送给streamlit, 注册CustomEvent 事件给st. const event = new CustomEvent("savePathReceived", {detail: save_path}); window.parent.document.dispatchEvent(event); } trigger_saving() </script> """ st.components.v1.html(get_save_path_html, height=0) #调用. return with st.form(key='create_prints'): submit_button = st.form_submit_button(label='选择目录并创建') if submit_button: #通过js事件,获得选择的目录,并且记录在st.session_state中。 save_path = st.session_state.get('save_path_result',None) # 注册callback event handler,通过streamlit experimental_set_query_params机制实现。 if save_path is None : # st.experimental_get_query_params, 返回当前streamlit的query参数(以dict的形式),这个值只有在第一次才生效。 event_result = st.experimental_get_query_params() get_save_path_with_dialog() js_code = f""" <script> //监听函数 function savePathCallBack_listen () {{ // 创建listener function const save_path_event_listener = window.parent.document.addEventListener('savePathReceived', (event) => {{ var savePath_hidden = window.parent.document.getElementById("savePathValue"); //找到或者创建一个div id=savePathValue if (!savePath_hidden) {{ savePath_hidden = window.parent.document.createElement("input"); savePath_hidden.type = "hidden"; savePath_hidden.id = "savePathValue"; window.parent.document.body.appendChild(savePath_hidden); }} savePath_hidden.value = event.detail; //给值. // 调用Streamlit Callback,执行_stMarkdown 的callback var streamlitCallback = window.parent.window["_streamlit_JsToStFunctions"]; if (streamlitCallback && streamlitCallback["_stMarkdown"]){{ streamlitCallback["_stMarkdown"]("savePathValue") // callback. }}; }}); }} savePathCallBack_listen();// 触发 </script> """ st.components.v1.html(js_code, height=0) # callback 到 python这边时,再从这里读取id和值 st.markdown( """ <script> window._streamlit_JsToStFunctions = window._streamlit_JsToStFunctions || {}; // _stMarkdown函数的内容,通过query string传递 window._streamlit_JsToStFunctions["_stMarkdown"] = (id) => { // 找到 savePathValue 的 value, 把值填写到 URL Query parameter var save_path_hidden = window.parent.document.getElementById("savePathValue"); var encoded_id = encodeURIComponent(id); var encoded_value = encodeURIComponent(save_path_hidden.value); const url = new URL(window.parent.location.href) url.searchParams.set("stCallBackKey", encoded_id) url.searchParams.set("stCallBackValue", encoded_value) window.parent.location.replace(url.toString())//页面重载, trigger st rerunning }; </script> """, unsafe_allow_html=True, ) #解析query parameter中的 stCallBackKey,stCallBackValue if 'stCallBackKey' in event_result and event_result['stCallBackKey'][0] == 'savePathValue': selected_path = event_result['stCallBackValue'][0] #记录起来. if st.session_state.get('save_path_result',None) != selected_path: st.session_state['save_path_result'] = selected_path #如果path有更新就rerun. if event_result and (st.session_state.get('save_path_result',None) is None or st.session_state.get('save_path_last', None) != st.session_state.get('save_path_result',None)): st.session_state['save_path_last'] = st.session_state.get('save_path_result',None) st.experimental_rerun() if st.session_state.get('save_path_result',None) : st.write('选择目录为:' + st.session_state.get('save_path_result',None) ) if st.session_state.get('save_path_result',None): os.makedirs( st.session_state.get('save_path_result',None), exist_ok=True) else: st.error('创建文件夹失败!') else: st.info('请选择目录!')
-
优势:
- 跨平台:支持Windows, Mac , Linux。
- 不需要特殊权限: 不需要担心提权的问题.
- 用户友好度高.
-
安全建议: 虽然这种方法相对更安全, 但仍要注意对用户最终选择的路径进行基本的验证 (比如是否为空, 是否是预期的路径等), 以避免潜在的问题。
总结与建议
选择哪种方案主要看具体需求和使用场景. 方法2(系统对话框)是兼容性,用户体验最好的方式.
如果必须固定文件读写的目录, 且应用场景单一,那么也可以选择方法1,用Electron APIs。 但记得正确处理不同操作系统的区别和做好安全措施!
无论是用哪种方法,解决在Streamlit程序中通过exe的本地读写问题都需要综合考虑多方面,通过本文章介绍的方法,相信可以让你游刃有余。