返回

解决 Streamlit 应用打包 EXE 后本地文件读写问题

windows

Streamlit 应用打包成可执行文件(.exe)后本地读写问题

我把一个 Streamlit 应用通过 npm 和 electron-builder 打包成了一个 .exe 文件。应用本身的 Streamlit 组件,比如图表、按钮、过滤器等,都能正常工作。但我想通过这个打包后的程序在本地进行文件读写(例如保存 matplotlib 生成的图片),却遇到了问题。

我原本尝试使用 os.getcwd() 来获取当前工作目录, 并在此基础上创建新的文件夹和文件, 但这个方法在打包后的程序里行不通。 看起来打包后的应用是在某种特殊的“沙盒”环境里运行,而不是直接在 Windows 系统层面运行。

下面来深入研究这个问题并搞定它!

一、问题原因分析

这个问题的根源在于打包过程和运行环境的改变。

  1. 打包后的执行环境: 使用 Electron 和 stlite 打包 Streamlit 应用,本质上是创建了一个独立的运行环境。 这个环境包含了一个迷你的浏览器(Chromium 内核)和一个 Node.js 环境来运行你的 Python 代码。 这就意味着你的应用不再直接与宿主操作系统(这里是 Windows)打交道。

  2. os.getcwd() 的局限性: os.getcwd() 返回的是 当前工作目录。 在开发环境中(直接运行 Streamlit),这个目录通常是你启动 Streamlit 服务的目录。 但在打包后的应用中,这个“当前工作目录”就变成了 Electron 应用内部的某个目录,而不是你期望的用户桌面或者可执行文件所在的目录。

  3. 权限问题(潜在): 即使你设法获取到了正确的路径, 写入文件时还可能会遇到权限问题, 特别是写入系统目录时。

二、解决方案

针对上面的分析,以下是几种可行的解决方案。

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.jsonbuild.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的本地读写问题都需要综合考虑多方面,通过本文章介绍的方法,相信可以让你游刃有余。