项目主体

基于 Streamlit 进行可视化,默认端口 8501.

基本运行

直接运行

采用了声明式的网页描述范式,所见即所得。

1
streamlit run Index.py

脚本模式

脚本模式下便于 PyInstaller 封装。需要一个 .py 脚本作为项目入口。

说明如下。

  1. .streamlit/config.toml
    端口配置等,可加可不加,但为了避免打包后出现奇怪告警和subpages找不到等问题,建议加上
  2. hooks/
    用于打包的钩子文件
  3. run_index.py
    封装 index.py,作为 Streamlit 运行脚本和 PyInstaller 打包入口
  4. run_index.spec
    打包配置项

目录说明

config.toml

1
2
3
4
5
6
7
[server]
port=8531
headless=true
[browser]
gatherUsageStats=false
[global]
developmentMode=false

不过也发现,打包为单文件后 .streamlit/ 目录找不到,即便在 __MEIPASS 下搜索也不行。所以在 run_index.py 中实际上是通过 sys.argv 传入配置,而非 config.toml.

hooks/hook_streamlit.py

按照常见实现即可。

run_index.py

用于脚本模式拉起 Index.py,启动命令为

1
python3 run_index.py

这样做还有一个好处,在 dev 模式下,不需要为每个文件都导一遍包源码的路径,只需在 run_index.py 导入即可;这因为 run_index 生命周期覆盖了所有 pages 的生命周期。

关于 run_index.py

首先明确 PyInstaller 对 Streamlit 打包后的最简结构。

  1. .exe
  2. Index.py
  3. pages/
  4. .streamlit/

页面文件和配置文件无法打包进应用程序。Streamlit 需要在当前的执行路径 sys.getcwd 下,并且还要让 .exe 能够找到页面文件。

如果只是打包为单个文件夹即 --onefolder,这一步已经够了,只需在 Electron Builder 中打包生成的整个 folder 即可。

如果希望继续打包为单文件,则有如下方式。

Enigma Virtual Box

该工具提供了一个虚拟文件层,可以将任意文件或目录结构打包成单个可执行文件。可以将上一节提到的目录结构打包为单个文件,但运行时的性能问题严重。推测是动态语言打包成可执行文件时,访存压力较大,虚拟文件层成为性能瓶颈。

继续通过 PyInstaller 打包

打包器支持打包 python 文件以外的数据和配置文件,并且支持指定打包后的相对路径。

在 .spec 中进行如下配置:

1
2
datas += [('Index.py', '.')]
datas += [('pages/page.py', 'pages')]

二元组的首个元素是待打包文件相对于 .spec 的文件路径,第二个元素是项目运行后,期待该文件所在的目录路径。

但实际打包后,还需要一步查找临时解压目录的步骤。这里要提到 PyInstaller 打包单文件的运行逻辑,是将各种文件解压到临时目录,在这个过程中,数据文件的路径会改变,直接使用 .spec 指定的相对路径无法找到文件。

明确这一点后便可以通过动态切换 run_index 执行路径来找到数据文件。又发现即使切换到临时目录,config.toml 仍然不生效,所以采用 sys.argv 传参方式传入配置。

最终的 run_index 如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import streamlit.web.cli as stcli

def resolve_path(path):
resolved_path = os.path.abspath(os.path.join(os.getcwd(), path))
return resolved_path

if __name__ == "__main__":
# 检测当前是否进行了打包,若打包,则切换到临时解压路径
try:
base_path = sys.__MEIPASS
except Exception:
bath_path = os.path.abspath('.')

os.chdir(base_path)

boot_pth = resolve_path('Index.py')
sys.argv = [
'streamlit',
'run',
boot_pth,
# 通过 sys.argv 传入 streamlit 配置
'--server.port=8531',
'--server.headless=true',
'--browser.gatherUsageStats=false',
'--global.developmentMode=fakse',
]
sys.exit(stcli.main())

至此,解决了入口文件问题。一份文件可以同时支持本地脚本运行与 PyInstaller 打包两种模式,打包后的 .exe 可放在任意路径下。

关于 run_index.spec

可以直接把 python 文件打包成 exe,也可以经由 spec 文件进行个性化配置。

生成 .spec 文件:

1
pyinstaller --onefile --additional-hooks-dir=./hooks Index.py --clean

以下列出了 .spec 文件需要添加或修改的部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 打包运行库
# if Linux
datas = [('$YOUR_PYTHON_PATH/lib/python3.x/site-packages/streamlit/runtime', './streamlit/runtime')]

# if Windows 双反斜杠不可少,否则拼接地址时偶发报错
datas = [('$YOUR_PYTHON_PATH\\lib\\python3.x\\site-packages\\streamlit\\runtime', './streamlit/runtime')]

# Streamlit 相关
datas += collect_data_files('streamlit')
datas += copy_metadata(streamlit)

# 把用于描述网页的 .py 文件作为数据打包
datas += [('Index.py', '.')]
datas += [('pages/Your_page_name.py', 'pages')]

# 以下处理比较暴力,由于找不到自封包,直接手动导入
hiddenimports = ['trainingtools']
hiddenimports += ['trainingtools.cli']
hiddenimports += ['trainingtools.eyetool']
hiddenimports += ['trainingtools.cli.utils']
hiddenimports += ['trainingtools.eyetool.parser']

a = Analysis(
['run_index.py'],
pathex = ['.'],
hiddenimports = hiddenimports,
hookspath = ['./hooks/'],
win_no_prefer_redirects=False,
win_private_assemblies=False,
noarchive=False,
cipher=None
)

EXE 部分,配置 name / debug / comsole 三个属性即可。

最后使用 .spec 生成 .exe

1
pyinstaller run_index.py --clean

踩坑

  • os 模块找不到波浪号目录。请使用专门的用户目录定位命令。