怎么样打包 pyqt 应用才是最佳方案?或者说 pyqt 怎样的发布方式最优?


本文由 简悦 SimpRead 转码, 原文地址 www.zhihu.com 韦易笑​

早先看一堆人说 PyQt 打包麻烦,部署困难的,打出来的包大(几十兆起步),而且启动贼慢,其实 Python+PyQt 打包非常容易,根本不需要用什么 PyInstaller,我手工打包出来的纯 Python 环境只有 5MB,加上 PyQt 也才 14MB。

很多人用 PyInstaller 喜欢加一个 -F 参数,打包成一个单文件:

这样的单文件看起来似乎很爽,其实他们不知道,这其实是一个自解压程序,每次运行时需要把自己解压到 temp 目录,然后再去用实际的方式运行一遍解压出来的东西:

Process Explorer 把雷达图标拖动到 pyqt_hello.exe 的窗口上,可以看到有两个 pyqt_hello.exe 的文件,外面那个是你打包出来的,里面那个才是真正的程序(虽然可执行都是一个),看看它下面依赖的 python310.dll 是在哪里?这不就是一个临时解压出来的目录么:

看到没?这就是你 PyInstaller 打包出来的 30MB 的程序,每次运行都要临时解压出 71MB 的文件,运行完又删除了,那么如果打包出来的可执行有 100MB,每次运行都要释放出 200-300 MB 的东西出来,所以为什么 PyInstaller 出来的单文件运行那么慢的原因除了每次要解压外,还有杀毒软件碰到新的二进制都要扫描一遍,你每次新增一堆 .dll , .pyd, .exe,每次都要扫描,不慢可能么?

其实 PyInstaller 如果不打包成单文件可执行(-F 参数),用起来问题不大,唯一不足有两个,首先是很多动态库其实我没用比如上面的 _socket,_ ssl, QtQuick 等,但都被打包的时候打进去了,大小会偏大;其次是目录看起来很乱,上百个文件一个目录,找主程序都找不到。

正确的打包姿势

当然是手工打包,现在 Python 3.5 以后,官方都会发布一个嵌入式 Python 包:

链接在这里:Python Release Python 3.8.10

现在不是都到 Python 3.10 了么,为什么选择 3.8 ? 因为 3.8 是最后一个支持 Win7 的版本,3.9 以后就不支持了。那么为什么选择 32 位?因为打包出来 32 位是最紧凑的,64 位会大很多,除非你要一次性在内存里 load 2GB 以上的数据,否则基本就选择 32 位的。

这个 32 位的包很小:

本身也只有 7MB,解压出来是一些必要的文件:

那么你在项目路径里建立一个新的 runtime 文件夹,把这些文件放进去,外层写个批处理,调用一下里面的 python.exe 基本就可以跑个命令行的程序了。当然这样看起来很原始,所以精细一点的话,为这个 embedded python 做一个壳,直接加载里面 python3.dll 或者 python38.dll 来运行程序。

嵌入式 Python 加壳

上面说的加壳我写了个例子了,叫做 PyStand:

https://github.com/skywind3000/PyStand

Release 下载下来是这样:

选择第一个 PyStand-py38-pyqt5-lite 这个包,下载下来 14MB,解压后:

目录非常清爽,比 PyInstaller 非单文件那种上百个 dll 的目录干净多了,就几个文件:

  • runtime:之前官方包 embedded python 解压后的内容。
  • site-packages:第三方依赖
  • PyStand.exe:主程序入口。
  • http://PyStand.int:脚本入口。

这个 PyStand.exe 就是可以直接运行的程序,双击:

运行成功,打包文件只有 14MB,就能跑一个完整 PyQt5 的项目了,比 PyInstaller 的 30MB 小不少,你就是解压开也才 40MB,比 70MB 的 PyInstaller 解压后大小精简很多。

主要代码就是写在 PyStand.int 里,这个 PyStand.exe 启动后会自动加载同名的 .int 文件:

import sys, os
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
app = QApplication([])
win = QWidget()
win.setWindowTitle('PyStand')
layout = QVBoxLayout()
label = QLabel('Hello, World !!')
label.setAlignment(Qt.AlignCenter)
layout.addWidget(label)
btn = QPushButton(text = 'PUSH ME')
layout.addWidget(btn)
win.setLayout(layout)
win.resize(400, 300)
btn.clicked.connect(lambda : [
        print('exit'),
        sys.exit(0),
    ])
win.show()
app.exec_()

也就是说可执行叫做 PyStand.exe ,它会加载 http://PyStand.int;如果改名叫做 MyDemo.exe 它就会加载 MyDemo.int 里的代码。

那么其实大家可以直接使用了,把程序名字改成你想要的编写对应的 .int 即可,换图标的话,可以自己重新编译 PyStand 项目,或者直接 Resource Hacker 更换图标:

根本不需要配置 C/C++ 编译环境。

安装依赖

我们需要一个对应版本号的 32 位的完整 python 3.8,然后新建个干净的虚拟环境:

\path\to\py38\python.exe  venv test

生成 test 目录里,大概目录是这样:

用 cmd.exe 进入 Scripts 目录,运行 activate 后,用 pip 安装你需要的包,然后到上面虚拟环境的 Lib/site-packages 里,把你需要的包找出来:

拷贝到 PyStand.exe 所在目录的 site-packages 里面即可使用,注意多余的,没有依赖的东西无需拷贝,比如上图的 pip 包

裁剪依赖

现在你已经把依赖的包拷贝到 PyStand 的 site-packages 里了,比如 PyQt5 这个包:

进去看一眼,把你不要的模块全部删除,什么 OpenGL,AxContainer,Multimedia,Position, Location, RemoteObject, DBus, QtQuick, QtWebEngine 之类的,不确定的可以删除了运行下你的程序测试下行不行,不行的又拷贝回来。

然后继续进入上图 Qt5 的目录里的 bin 目录:

这里有很多尺寸很大的模块,继续删除多余的,什么 QML,Test,Help,OpenGL ,GLES,Sensor, Bluetooth 之类的全部干掉,再到上层:

接着精简 plugins 目录里不要的东西,删除 qml/qsci, 然后到 translations 里把不要的语言删除掉,这么一圈下来,整个目录从原来的 134MB:

精简到 46.8 MB:

比你 PyInstaller 解压出来的 70MB 小了一大半,打包出来就是 14MB:

我已经帮你裁剪好了,你可以直接使用,还有好多可以根据你程序的需要进行裁剪,比如 openssl,sqlite 这些都非常大,总之还有很多压缩空间,按照你的程序需要还可以二次裁剪。

手工裁剪比无脑 PyInstaller 可靠的多,不但可以精细裁剪,每一步你都清晰的知道是怎么来的,出了问题你也知道该怎么回退。

二进制压缩

还可以用 upx 压缩一些比较大的文件,但 runtime 下面的 python3.dll, python38.dll, vcruntime140.dll 不能压缩,而 PyQt5 里的 QtCore, QtWidgets, QtGUI 不能压缩,一边测试一边压缩,还可以进一步精简。

代码组织

你有很多 py 代码,可以在 PyStand 下面新建一个 script 目录:

在里面放一个 main.py,实现一个 main 方法,然后改写 http://PyStand.int

import sys, os
os.chdir(os.path.dirname(__file__))
sys.path.append(os.path.abspath('script'))
sys.path.append(os.path.abspath('script.egg'))
import main
main.main()

这个代码就是做了三件事情:矫正当前运行目录,设置 sys.path,然后导入 main 模块并执行 main 方法。注意后面 sys.path 里追加了一个 script.egg,意思是你调试好了,发布时把 script 目录里面的代码或者 pyc 压缩成要给 zip 文件,叫做 script.egg 放在 PyStand 那里删除 script 目录即可:

发布出来大概是这样,运行 PyStand.exe 成功的 import 到了 main.main() 函数:

主目录下面就三个文件,打包放到其他机器上解压就运行,不喜欢 PyStand.exe 这个名字可以随便改,同时修改 PyStand.int 的名字即可:

比如这样,运行 PyQt-Demo.exe 它会根据自身的名字,正确的找到 PyQt-Demo.int 文件并执行。

基础加密

要求不高的话,上面你将 script 目录内的 .py 文件打包成 script.egg,直接就可以发布了,至少不会满目录的 .py 文件。要求高一点的话,把 .py 先转换成 .pyc 再压缩成 script.egg,然后把关键几个模块用 cython 之类的工具转换成 .pyd 即可。

上面基础加密基本够用了,个人开发者可以就此止步,如果你是一个团队,要发布面向百万以上用户产品级的东西,追求比 PyInstaller 更安全的加密方式可以继续往下。

高级加密

接下来技巧我在 Python 2 时代都做过,你可以视精力酌情添加:

第一层:pyc 加密,自己写一个 importer,放到 PyStand.int 里初始化,作用是加载自定义的 .pz 文件,而 .pz 文件是根据 .pyo 文件加密得到,你的 importer 负责解密并加载这些字节码,把这个 importer 添加到 sys.path_hooks 里面,这样 python 就能 import .pz 文件了,再写个批处理,把项目文件全部编译转换成 .pz 压缩成 script.egg。

第二层:zip 文件加密码,参考 python 自带的 zipimporter 实现一个 zipimporter2,支持 zip 文件加密码,只要在 sys.path.append('script.egg@12345') 类似这样的路径,就可以按给定密码 import zip 内的东西,当然密码可以写的不那么明显,还可以支持 7zip 导入。

第三层:将 .dll/.pyd 封装近 python38.zip 或者 script.egg 内,这里你会用到 py2exe 的两个子模块:MemoryModule:

地址:https://github.com/py2exe/py2exe/tree/master/source

可以用来从内存加载 dll/pyd,然后还有一个 zipdllimporter 的脚本,可以从内存 / zip 文件直接加载 pyd/dll,这样你的所有的 pyd/dll 都可以塞到 .zip/.egg 文件里了,根本不用暴露。

第四层:源代码重新编译 Python,将很多东西直接编译进去,比如上面说的各种 importer 实现,memory importer,加密 zip 文件之类的,并且支持加密的 http://PyStand.int

第五层:修改字节码,找到 python 源代码的 include/opcode.h

自己魔改一遍,基本反编译的程序都蒙圈了,再到 Include/internal/pycore_ast.h 下面修改一些结构体的内部顺序,这样只要对方没有你的头文件,想从进程内存级别 intercept 进来获取字节码或者 ast 的都会非常麻烦。

第六层:静态编译,把所有第三方库和 python 自己静态编译成一个 exe 或者 dll,没有任何依赖,不暴露任何 dll 接口,集成上面说过的所有功能。由于你全部依赖都静态编译了,所以可以给 PyObject 里加两个无关的成员,调整一下已有成员顺序,别人就是进程截取 PyObject 的指针,由于没有头文件,内部结构不知道,所以它也没有任何办法。

第七层:你的可执行每次启动会检测可执行文件末尾,是否有添加的内容,如果有,把他视为一个加密的压缩包,在内存里解密并 import 对应模块,这样你上面的单个程序就可以和具体逻辑相分离,有了新的逻辑代码,压缩加密后添加在唯一可执行尾部即可。

。。。。。

还有很多类似的方法,这里仅仅抛砖引玉,没有绝对的安全,就是看你愿意投入多少人力,可以做到哪一层,个人的话,上面基础加密足够了,公司团队的话,安排人搞个一两个月,基本也就搞定了。

错误调试

这个 PyStand.exe 是窗口程序,那么出错了怎么看 exception 呢?可以打开一个 cmd.exe,用 cmd.exe 启动 PyStand,就能看到错误了,你自己也可以记录下日志,catch 一下内部的 exception

--

更多阅读:为什么很多 Python 开发者写 GUI 不用 Tkinter,而要选择 PyQt 和 wxPython 或其他?

知乎用户

之前追求极致 exe 启动速度, 用 py2exe, 独立打包后, 启动 EXE 飞快, 应该是内存虚拟解压, 不像 pyinstaller 文件方式解压到 temp. 但最近买了代码签名证书后发现 py2exe 的 exe 加了签名后会无法启动, 老外论坛说签名会破坏 exe 的 MD5. 只能再次用 pyisntaller. 无意间发现有 enigma 这种软件, 也是内存虚拟映射解压, 速度飞起, 还支持代码签名. 唯一缺点就是压缩比还是有点低, 比 rar 同等压缩要大 20% 左右.

我感觉这种方式是挺好的, 能完美保留开发环境配置, 不用刻意去精简什么. 速度还快, 稳定最重要.

还有一点:

互联网产品一般还有安装器, 卸载器, 更新器的需求, 而这三个完全都能通过自身复制独立 exe 和独立 DLL, 让安装包缩小三倍.

你不认识我

我业余写 python 玩,最近写了个小工具,同事们也想要用,总不能让他们也下载 python 然后安装各种包吧,所以需要进行打包。

先参考了韦神的帖子:https://www.zhihu.com/question/48776632/answer/2336654649

他这种手动打包的方式我很喜欢,但他自己写了个程序,作为外壳进行程序的加载。我这儿不需要加密,也希望能够深入的做一些了解,就自己做了一些工作。这儿做个记录,也给大家一个参考。

第一步:下载嵌入式 python,并解压。 注意,这个 python 的版本和架构必须和你用的版本是一模一样的。

第二步:建立如下目录结构

  1. 主目录名称:test
  2. 主目录下面存放自己写的程序,main.py 和 other.py
  3. 主目录下建立一个子目录:runtime,并将解压后的嵌入式 python 拷贝到这个目录下面。
  4. runtime 下建立子目录 Lib,用来存放第三方库。

第三步:把普通 python 下的库直接拷贝到嵌入式 python 的 Lib 目录下。

普通 python 的第三方库在 \ Python39\Lib\site-packages 目录下面。再次申明,两个 python 的架构和版本必须一致。

第四步:关键步骤,编辑 python39._pth

嵌入式 python 下载并解压之后,里面有个很重要的文件:python39._pth,(你的文件名因为 python 版本的问题可能和我不同)这个文件规定了嵌入式 python 加载库文件的路径。打开可以看到其内容:

python39.zip
.
# Uncomment to run site.main() automatically
#import site
  1. 第一行:python39.zip 的意思是从这个压缩文件里面加载库,我们打开这个压缩文件可以看到这个压缩文件下面就是各种标准库
  2. 第二行:当前目录
  3. 后面都是注释,不用关注

我们需要增加两行

  1. 增加 runtime 下的 Lib 目录,用于加载第三方库。
  2. 增加主目录,用于加载自己的程序,比如 other.py 之类

此时,此文件内容如下

python39.zip
.
Lib
..\  #主目录
# Uncomment to run site.main() automatically
#import site

第五步:建立一个 go.bat 文件,用来进行启动。

在主目录 test 下,建立一个 go.bat,其内容很简单,就一行。

.\runtime\python.exe main.py

到此,就基本成功了,但我碰到了几个问题,有知道的请评论区解答。

  1. 理想情况下,如果能把第三方库都打包成 zip,那可以大大减小打包文件的大小,我这样做了,也把路径加到了 pth 文件里面。但是出错了,不能加载 wxpython。我不清楚是 wxpython 的问题还是其他问题造成的。
  2. 我的程序里面用了 prettytable,我把它拷贝到 lib 目录下之后,加载失败,还需要把另外一个目录 “prettytable-3.2.0.dist-info” 也加进去才行。Jessi

可以看看这个:

python 如何连同依赖打包发布以及 python 的构建工具? - Python

可能打包依赖文件会方便点。

峰哥 python 笔记

布局管理是指我们在应用程序窗口上放置小部件的方式。我们可以使用绝对定位或布局类来放置小部件。使用布局管理器来管理布局是组织小部件的首选方式。

绝对定位

程序员指定每个小部件的位置和大小(以像素为单位)。当使用绝对定位时,我们需要理解以下限制:

  • 如果调整窗口大小,小部件的大小和位置不会改变。
  • 应用程序在不同平台上可能显示不同。
  • 更改应用程序中的字体可能破坏布局。
  • 如果决定更改布局,必须完全重新设计布局,这是繁琐且耗时的工作。 以下示例在绝对坐标中放置小部件。
class Example(QWidget):

    def __init__(self):
        super().__init__()

        self.initUI()
        self.setWindowTitle("桥然:布局测试")
        self.resize(600, 400)
        self.setWindowIcon(PyQt6.QtGui.QIcon("logo.jpg"))

    def initUI(self):

        lbl1 = QLabel('桥然:', self)
        lbl1.move(150, 60)

        lbl2 = QLabel('tutorials', self)
        lbl2.move(350, 80)

        lbl3 = QLabel('for programmers', self)
        lbl3.move(450, 100)

        self.setGeometry(300, 300, 350, 250)
        self.setWindowTitle('Absolute')
        self.show()

def main():

    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

我们使用 move 方法来定位我们的小部件。在我们的例子中,这些是标签。我们通过提供 x 和 y 坐标来定位它们。坐标系统的起点位于左上角。x 值从左到右增长。y 值从上到下增长。

PyQt6 QHBoxLayout 和 QVBoxLayout

QHBoxLayoutQVBoxLayoutPyQt6 中常用的布局管理器类,用于在应用程序中水平和垂直地排列小部件。

  • QHBoxLayout(水平布局)将小部件水平排列,从左到右摆放,可以根据需要添加伸缩因子。
  • QVBoxLayout(垂直布局)将小部件垂直排列,从上到下摆放,同样可以根据需要添加伸缩因子。

这两个布局管理器类的用法非常类似,以下是它们的一般用法和常用参数:

QHBoxLayout 的用法:

layout = QHBoxLayout()

QVBoxLayout 的用法:

layout = QVBoxLayout()

使用以上代码,我们可以创建一个空的水平布局或垂直布局对象。接下来,我们可以通过以下方法将小部件添加到布局中:

  • addWidget(widget, stretch=0, alignment=Qt.Alignment)

  • widget:要添加的小部件对象。

  • [stretch](https://www.zhihu.com/search?q=stretch&search_source=Entity&hybrid_search_source=Entity&hybrid_search_extra=%7B%22sourceType%22%3A%22answer%22%2C%22sourceId%22%3A3334746121%7D):可选参数,指定伸缩因子,默认为 0。伸缩因子决定了小部件在布局中的占用空间比例。

  • alignment:可选参数,指定对齐方式,默认为 Qt.Alignment。用于控制小部件在布局中的对齐方式。 例如,添加一个按钮到水平布局中:

button = QPushButton("Button")
layout.addWidget(button)

还可以添加多个小部件,并使用伸缩因子进行布局控制:

button1 = QPushButton("Button 1")
button2 = QPushButton("Button 2")
layout.addWidget(button1, 1)  # 伸缩因子为1
layout.addWidget(button2, 2)  # 伸缩因子为2

在以上示例中,button1 将占据 1/3 的水平空间,button2 将占据 2/3 的水平空间。

除了 addWidget 方法之外,还可以使用其他方法对布局进行进一步设置和操作,例如:

addLayout(layout, stretch=0):添加另一个布局对象到当前布局中。 addSpacing(spacing):添加指定的间距(以像素为单位)。 addStretch(stretch=0):添加伸缩因子,用于平均分配剩余空间。 setSpacing(spacing):设置小部件之间的间距(以像素为单位)。 以上是 QHBoxLayoutQVBoxLayout 的基本用法和常用参数。根据实际需求,你可以根据文档进一步了解它们的更多方法和属性。

class Example(QWidget):

    def __init__(self):
        super().__init__()

        self.initUI()
        self.setWindowTitle("桥然:布局测试")
        self.resize(600, 400)
        self.setWindowIcon(PyQt6.QtGui.QIcon("logo.jpg"))

    def initUI(self):

        okButton = QPushButton("OK")
        cancelButton = QPushButton("Cancel")

        hbox = QHBoxLayout()
        hbox.addStretch(1)
        hbox.addWidget(okButton)
        hbox.addWidget(cancelButton)

        vbox = QVBoxLayout()
        vbox.addStretch(1)
        vbox.addLayout(hbox)

        self.setLayout(vbox)

        self.setGeometry(300, 300, 350, 250)
        self.setWindowTitle('Buttons')
        self.show()

def main():

    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

定义了一个名为 Example 的类,继承自 QWidget 类,用于创建窗口应用程序。

Example 类的__init__方法中,调用了父类的构造函数,并初始化了窗口的 UI 界面。

initUI 方法用于初始化用户界面,包括创建按钮、设置布局和添加按钮到布局中。

首先,创建了两个按钮对象:okButtoncancelButton

然后,创建一个水平布局对象 hbox,通过 addStretch 方法添加一个弹性空间,使按钮能够水平居中显示,并使用 addWidget 方法将两个按钮添加到水平布局中。

接下来,创建一个垂直布局对象 vbox,通过 addStretch 方法添加一个弹性空间,使按钮能够垂直居中显示,并使用 addLayout 方法将水平布局添加到垂直布局中。

最后,通过 setLayout 方法将垂直布局设置为窗口的布局。

main 函数中,创建了一个 QApplication 对象 app,实例化了 Example 类,并通过 sys.exit(app.exec()) 进入应用程序的主循环。

运行该代码,会显示一个窗口,窗口中包含 "OK" 和 "Cancel" 两个按钮,按钮水平居中显示,同时垂直居中于窗口。

QGridLayout 案例

QGridLayoutPyQt6 中的一个布局类,它将窗口 / 对话框分割为行和列,我们可以指定控件的位置和跨越的行列数。

创建 QGridLayout 对象的基本语法如下:

layout=QGridLayout()

一旦创建了 QGridLayout 对象,就可以使用 addWidget() 方法将控件添加到网格中。addWidget() 方法的参数是要添加的控件和控件的行号和列号。例如:

layout.addWidget(button,0,0)

这将 button 控件添加到网格的第 0 行和第 0 列。

如果你想让一个控件跨越多行或多列,可以在 addWidget() 方法中添加两个额外的参数,分别表示控件所跨越的行数和列数。例如:

layout.addWidget(textEdit,1,1,3,3)

这将 textEdit 控件添加到网格的第 1 行和第 1 列,且 textEdit 控件跨越了 3 行和 3 列。

QGridLayout 还有一个 setSpacing() 方法,用于设置网格中的单元格之间的间距。例如:

layout.setSpacing(10)

这将设置网格中的单元格之间的间距为 10 像素。

QGridLayout 会尽可能平均地分配空间给每个控件,但是如果某个控件需要更多的空间(例如,因为它的内容较多),那么 QGridLayout 会自动调整空间分配。

最后,可以使用 setLayout() 方法将 QGridLayout 对象设置为窗口或对话框的布局。例如:

self.setLayout(layout)

这将设置窗口的布局为 layout

计算器

class Example(QWidget):

    def __init__(self):
        super().__init__()

        self.initUI()
        self.setWindowTitle("桥然:Calculator")
        self.resize(300, 200)
        self.setWindowIcon(PyQt6.QtGui.QIcon("logo.jpg"))

    def initUI(self):

        grid = QGridLayout()
        self.setLayout(grid)

        names = ['Cls', 'Bck', '', 'Close',
                 '7', '8', '9', '/',
                 '4', '5', '6', '*',
                 '1', '2', '3', '-',
                 '0', '.', '=', '+']

        positions = [(i, j) for i in range(5) for j in range(4)]

        for position, name in zip(positions, names):

            if name == '':
                continue

            button = QPushButton(name)
            grid.addWidget(button, *position)

        self.move(300, 150)
        self.setWindowTitle('Calculator')
        self.show()

def main():

    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

意见反馈表

import sys,PyQt6
from PyQt6.QtWidgets import (QWidget, QLabel, QLineEdit,
        QTextEdit, QGridLayout, QApplication)

class Example(QWidget):

    def __init__(self):
        super().__init__()

        self.initUI()
        self.setWindowTitle("桥然:GridLayout布局测试")
        self.resize(600, 400)
        self.setWindowIcon(PyQt6.QtGui.QIcon("logo.jpg"))

    def initUI(self):

        title = QLabel('Title')
        author = QLabel('Author')
        review = QLabel('Review')

        titleEdit = QLineEdit()
        authorEdit = QLineEdit()
        reviewEdit = QTextEdit()

        grid = QGridLayout()
        grid.setSpacing(10)

        grid.addWidget(title, 1, 0)
        grid.addWidget(titleEdit, 1, 1)

        grid.addWidget(author, 2, 0)
        grid.addWidget(authorEdit, 2, 1)

        grid.addWidget(review, 3, 0)
        grid.addWidget(reviewEdit, 3, 1, 5, 1)

        self.setLayout(grid)

        self.setGeometry(300, 300, 350, 300)
        #self.setWindowTitle('Review')
        self.show()

def main():

    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

使用 PyQt6 创建一个简单的 GUI 应用程序,该应用程序具有一个网格布局(QGridLayout)和一些基本的控件(标签、文本编辑框和文本输入框)。

  1. 从 PyQt6.QtWidgets 模块中导入了必要的类。

  2. 定义了一个名为 Example 的类,它继承自 QWidget 类。QWidget 类是所有用户界面对象的基类。

  3. Example 类的构造函数中,调用了父类的构造函数,并调用了 initUI 方法来初始化用户界面。

  4. initUI 方法中,首先创建了一些 QLabelQLineEditQTextEdit 对象。

  5. 创建了一个 QGridLayout 对象,并设置了网格间的间距。

  6. 使用 addWidget 方法将控件添加到网格布局中。addWidget 方法的前两个参数是要添加的控件和控件所在的行号。第三个参数是控件所在的列号。最后两个参数(可选)是控件所占的行数和列数。

  7. 将网格布局设置为窗口的布局。

  8. 设置了窗口的几何形状(位置和大小)、标题,并显示了窗口。

  9. 在 main 函数中,创建了一个 QApplication 对象和一个 Example 对象,并进入了应用程序的主循环。

当运行这个脚本时,如果它是作为主脚本运行的(也就是说,它不是被其他脚本导入的),那么就会调用 main 函数。

上面代码简单修改,就可以实现发送邮件的功能。在原来的基础上,填选择附件,发送的按钮,余下就是把发邮件的功能与相应按钮绑定。

小修改

修改后的界面如下:

标题、收件人,可以手动输入,也可以加载时,直接自动填加,只需要选择附件,实现特定的邮件发送任务。

修改过程:

加载程序时,会把预先设置好的邮件标题,收件人,编写好的邮件内容,都填到相应的位置,加载程序后,可以给邮件标题,邮件内容进行修改。点击发送邮件时,读取标题的 QEditLine 的内容,做为邮件的标题,内容部分的内容,转化成 html 格式,作为邮件的内容。读取附件内容,作为信息的附件。

加载程序时,读取本地的 smtp 的授权码 (密码) 和收件人列表。

代码部分

class Example(QWidget):

    def __init__(self):
        super().__init__()

        self.initUI()
        self.setWindowTitle("桥然:GridLayout布局测试--发送邮件")
        self.resize(600, 400)
        self.setWindowIcon(PyQt6.QtGui.QIcon("logo.jpg"))
       #########################
        with open("pwd.ini",mode='r') as f:
            self.pwd = f.readline()
            print(self.pwd)
        df = pd.read_csv('smtp.ini' , sep = ',' , parse_dates=['email'])

        self.emails = []

        for i in range(0,df.shape[0]):
            ss = df['email'][i]
            self.emails.append(ss)
        my_string = ','.join(self.emails)
        self.authorEdit.setText(my_string)
        self.smtp_server = 'smtp.163.com'
        self.from_addr = '1****@163.com'

        html_msg = """
        <p>尊敬的领导:</p>
        <p>这是黑龙江省************专报,请查收!</p>
        """
        # 使用正则表达式去除HTML标签
        plain_text = re.sub('<.*?>', '', html_msg)
        print(plain_text.strip())

        self.reviewEdit.setText(plain_text)
        self.titleEdit.setText("黑龙江省***********专报")
        ############################################

    def initUI(self):

        title = QLabel('标题')
        author = QLabel('收件人')
        review = QLabel('内容')
        fujian = QLabel('附件路径')

        self.titleEdit = QLineEdit() #标题
        self.authorEdit = QLineEdit() #收件信箱
        self.reviewEdit = QTextEdit() #内容
        self.fujianEdit = QLineEdit() #附件路径

        grid = QGridLayout()
        grid.setSpacing(10)

        grid.addWidget(title, 1, 0)
        grid.addWidget(self.titleEdit, 1, 1)

        grid.addWidget(author, 2, 0)
        grid.addWidget(self.authorEdit, 2, 1)

        grid.addWidget(fujian, 3, 0)
        grid.addWidget(self.fujianEdit, 3, 1)

        grid.addWidget(review, 4, 0)
        grid.addWidget(self.reviewEdit, 4, 1, 5, 1)
        sent = QPushButton("发送邮件")
        sent.clicked.connect(self.sent)
        grid.addWidget(sent, 9, 1)

        select = QPushButton("选择文件")
        select.clicked.connect(self.select)
        grid.addWidget(select, 9, 0)
        self.setLayout(grid)

        self.setGeometry(300, 300, 350, 300)
        #self.setWindowTitle('Review')
        self.show()

    def sent(self):
        html_msg = self.reviewEdit.toHtml()
        print(html_msg)

        # 创建一个带附件的实例msg
        msg = MIMEMultipart()

        msg['From'] = Header("黑龙江******<1********@163.com>", 'utf-8')  # 发送者
        msg['To'] = Header('********', 'utf-8').encode()  # 接收者

        subject = self.titleEdit.text() 
        msg['Subject'] = Header(subject, 'utf-8')  # 邮件主题
        # 邮件正文内容
        msg.attach(MIMEText(html_msg, 'html', 'utf-8'))

        att = MIMEBase('application', 'octet-stream')
        att.set_payload(open(self.fujianEdit.text(), 'rb').read())
        ss = self.fujianEdit.text()
        filename = ss.split("/")[-1]
        att.add_header('Content-Disposition', 'attachment', filename=('gbk', '', filename) )
        encoders.encode_base64(att)

        msg.attach(att)

        try:
            smtpobj = smtplib.SMTP_SSL(self.smtp_server)
            smtpobj.connect(self.smtp_server, 465)    # 建立连接--qq邮箱服务和端口号
            smtpobj.login(self.from_addr, self.pwd)   # 登录--发送者账号和口令
            smtpobj.sendmail(self.from_addr, self.emails, msg.as_string())

            print("邮件发送成功")
            time.sleep(30)
        except smtplib.SMTPException as e:
            print(f"邮件发送失败,原因:{e}")
    def select(self):
        options = QFileDialog.Option(QFileDialog.Option.ReadOnly)
        file_dialog = QFileDialog()
        file_dialog.setOptions(options)
        file_dialog.setFileMode(QFileDialog.FileMode.ExistingFiles)
        file_dialog.setNameFilter("Documents files (*.doc *.docx)")
        if file_dialog.exec() == QFileDialog.DialogCode.Accepted:
            file_paths = file_dialog.selectedFiles()
            self.fujianEdit.setText(file_paths[0])
        #fileName, _ = QFileDialog.getOpenFileName(self,"QFileDialog.getOpenFileName()", "","All Files (*);;Python Files (*.py)", options=options)
        #if fileName:
        #    self.fujianEdit.setText(fileName)

def main():

    app = QApplication(sys.argv)
    ex = Example()
    sys.exit(app.exec())

if __name__ == '__main__':
    main()

技术细节

这段代码是一个使用 PyQt6 编写的简单的邮件发送应用程序。以下是对代码中主要功能和关键技术细节的解析:

  1. 导入所需的模块:
  • sys:系统相关的功能。

  • PyQt6:PyQt6 模块。

  • 其他相关模块和类,如 QWidget、QLabel、QLineEdit、QTextEdit、QGridLayout、QApplication、QPushButton、QFileDialog 等。

  • 导入 smtplib 和 email 库:

  • smtplib:用于 SMTP 邮件发送。

  • email.mime.text:用于构建纯文本邮件内容。

  • email.mime.multipart:用于构建多部分邮件内容。

  • email.header:用于构建邮件头部信息。

  • email.encoders:用于编码附件数据。

  • 创建 Example 类,继承自 QWidget:

  • __init__方法中初始化 UI 界面,包括设置窗口标题、图标、大小等,读取配置文件和数据,并将数据填充到界面的对应部件上。

  • 实现 initUI 方法:

  • 创建标签和输入框部件,并使用 QGridLayout 布局管理器进行布局。

  • 实现 sent 方法:

  • 获取用户在界面上填写的邮件标题、收件人、内容和附件路径等信息。

  • 使用 MIMEMultipart 创建一个带附件的邮件对象,并设置发件人、收件人、主题和正文内容。

  • 将附件添加到邮件对象中。

  • 连接 SMTP 服务器,登录发送者账号,发送邮件。

  • 实现 select 方法:

  • 创建 QFileDialog 对话框,允许用户选择附件文件。

  • 获取用户选择的附件文件路径,并将路径填充到界面上对应的输入框中。

  • 创建 QApplication 实例,实例化 Example 类,并运行应用程序。

关键技术细节包括:

  • 使用 PyQt6 创建 GUI 界面,并使用布局管理器(QGridLayout)来安排小部件的位置。
  • 使用 smtplib 库和 SMTP 协议发送邮件。
  • 使用 email 库构建邮件内容,包括邮件头部信息、纯文本或 HTML 格式的邮件正文、带附件的邮件等。
  • 通过 QFileDialog 选择文件,并将选择的文件路径填充到输入框中。

该应用程序的主要功能是允许用户填写邮件标题、收件人、内容和附件路径,并发送邮件。使用了 PyQt6 进行界面设计和布局管理,以及 smtplib 和 email 库来处理邮件发送和构建邮件内容。

存在的问题

可能邮件头的某些编码设置有问题,通过此方法发送的邮件,发送者信息不能正常显示,打开邮件,收件人部位也显示乱码。目前还不知道怎么解决。这个名称问题已解决,后续的文章会介绍方法。

声明:HEUE NOTE|版权所有,违者必究|如未注明,均为原创|本网站采用BY-NC-SA 4.0协议进行授权

转载:转载请注明原文链接 - 怎么样打包 pyqt 应用才是最佳方案?或者说 pyqt 怎样的发布方式最优?