当前位置: 代码网 > it编程>前端脚本>Python > Python+wxPython从零编写一个AES加 ZIP工具(附源码)

Python+wxPython从零编写一个AES加 ZIP工具(附源码)

2026年08月04日 Python 我要评论
一、需求与技术选型原始需求很朴素,但每一条都藏着一个技术决策点:需求决策点选择多个文件wx.filedialog(fd_multiple) + 拖拽 wx.filedroptarget输入 zip 名

一、需求与技术选型

原始需求很朴素,但每一条都藏着一个技术决策点:

需求决策点
选择多个文件wx.filedialog(fd_multiple) + 拖拽 wx.filedroptarget
输入 zip 名称 / 密码输入合法性校验(windows 非法字符、两次密码一致)
生成加密 zip标准库做不到,必须引第三方库
操作保存到数据库sqlite 双表 + 外键级联
配置保存并下次回填json + 字段白名单
打包 exe 后仍能定位同目录的配置和数据库这条最容易写错,见第二节
操作简单、界面美观自绘按钮、分组框、进度条、可取消

最终技术栈:

python 3.x
wxpython   4.2.4  (wxwidgets 3.2.8 msw, phoenix)   —— gui
pyzipper   0.4.0                                   —— aes 加密 zip 读写
pillow     11.3.0                                  —— 可选,图片解码兜底
sqlite3 / json / ctypes / threading                —— 标准库

安装:

pip install wxpython pyzipper pillow

为什么选 wxpython 而不是 pyqt/tkinter?

  • tkinter 自带但界面观感太"上世纪",listctrl 这类多列表格要自己拼;
  • pyqt/pyside 好看,但 lgpl/gpl 授权问题在商用场景上要额外考虑,打包体积也更大;
  • wxpython 用的是操作系统原生控件,windows 上天然就是 win 的观感,打包后体积可控。

代价是:原生控件意味着你要迁就系统主题的脾气。这个代价在第七节会具体咬人一口。

二、第一个坑:打包成 exe 后配置文件去哪了

这是需求里最硬的一条约束,也是新手最容易翻车的地方。

先看正确写法:

def app_dir():
    """程序所在目录:.py 直接运行取脚本目录,pyinstaller 打包后取 exe 目录。"""
    if getattr(sys, "frozen", false):
        # onefile 模式下 sys.executable 是 exe 本身,不是解包的临时目录
        return os.path.dirname(os.path.abspath(sys.executable))
    return os.path.dirname(os.path.abspath(__file__))


base_dir = app_dir()
config_path = os.path.join(base_dir, config_name)
db_path = os.path.join(base_dir, db_name)

三种错误写法,以及各自的死法

错法 1:直接用相对路径

config_path = "config.json"          # ❌

相对路径是相对于当前工作目录(cwd),不是程序目录。用户从桌面快捷方式启动,cwd 可能是 c:\windows\system32;从命令行 cd d:\ && python c:\tool\zip_maker.py 启动,cwd 是 d:\。结果就是配置文件"随机"散落在文件系统里,用户会觉得"我的设置怎么时有时无"。

错法 2:只用 __file__

base_dir = os.path.dirname(os.path.abspath(__file__))    # ❌ 打包后就错了

pyinstaller onefile 模式下,exe 运行时会把自己解压到一个临时目录(形如 c:\users\xxx\appdata\local\temp\_meixxxxx),__file__ 指向的是那个临时目录里的脚本。于是:

  • 配置写进了临时目录;
  • 程序一退出,临时目录连同你的配置和数据库一起被删除。

用户的体验是"每次打开都是空的,历史记录一条都没有"——而且几乎无法自查,因为文件确实被写成功过。

错法 3:用 sys._meipass

base_dir = sys._meipass                                   # ❌ 语义反了

很多博客会告诉你用这个。但 sys._meipass只读资源目录,语义是"我打包进去的图标、字体、模板在哪",不是"用户数据该写在哪"。往里面写数据,同样活不过进程生命周期。

为什么sys.executable是对的

运行方式sys.frozensys.executable__file__
python zip_maker.py不存在python 解释器路径脚本路径 ✅
pyinstaller onefile exetrueexe 自身路径临时解包目录 ❌
pyinstaller onedir exetrueexe 自身路径临时/内部目录 ❌

所以两个分支各取所需,正好互补。用 getattr(sys, "frozen", false) 而不是 sys.frozen 是因为未打包时这个属性根本不存在,直接访问会 attributeerror

最后一个细节——把路径显示在状态栏上,让用户(和未来的你)随时能自查:

self.createstatusbar()
self.setstatustext(f"配置: {config_path}    数据库: {db_path}")

这一行的排障价值远超它的代码量。任何"我的数据丢了"的报告,第一句话就能问清楚。

三、加密 zip:为什么标准库 zipfile 根本写不了

先破除一个普遍误解。python 标准库 zipfile 对加密的支持是极度残缺的:

import zipfile
zf = zipfile.zipfile("a.zip")
zf.setpassword(b"123456")     # ✅ 能用:解密读取传统 zipcrypto
zf = zipfile.zipfile("a.zip", "w")
zf.setpassword(b"123456")     # ⚠️ 有这个方法,但对写入完全无效
zf.write("f.txt")             # 结果:一个毫无加密的普通 zip

setpassword() 的文档写得很清楚:只用于解密。标准库从来没有实现过加密写入。所以那些"用 zipfile 加密压缩"的教程,产出的其实是明文 zip——而且不报错,静默失败,这才是最危险的。

而且即便标准库支持,传统 zipcrypto 也是一个 1990 年代的算法,已被已知明文攻击彻底攻破,有现成工具能在几分钟内破解。用它等于没加密。

pyzipper 的正确用法

pyzipperzipfile 的一个 fork,api 几乎完全兼容,但补上了 winzip aes 加密:

with pyzipper.aeszipfile(
        self.zip_path, "w",
        compression=pyzipper.zip_deflated,
        encryption=pyzipper.wz_aes,
) as zf:
    zf.setpassword(self.password.encode("utf-8"))
    zf.setencryption(pyzipper.wz_aes, nbits=self.nbits)
    ...
    zf.write(path, arcname)

三个必须注意的点:

  1. setpasswordsetencryption 都要调。只设密码不设加密方式,某些路径下不会真正启用 aes;setencryption 才是决定密钥长度的那个开关。
  2. 密码必须是 bytes,且编码要固定。这里统一用 utf-8,含中文的密码才不会在跨机器解压时对不上。
  3. aes 位数暴露给用户选。用一个字典把界面文案和数值绑在一起:
aes_bits = {"aes-256 (最强)": 256, "aes-192": 192, "aes-128 (兼容性好)": 128}
self.cho_enc = wx.choice(box_opt, choices=list(aes_bits.keys()))

界面拿 key、逻辑拿 value,无需维护第二份映射表,也不会出现"下拉框加了一项但逻辑忘了改"的错位。python 3.7+ 的 dict 有序,list(keys()) 的顺序就是你写的顺序,可以放心依赖。

兼容性提示:winzip aes 是事实标准,7-zip、winrar、winzip 都能正常解开。但 windows 资源管理器内置的"解压缩全部"不认,双击进去会提示密码错误或直接失败。这不是 bug,是系统资源管理器只支持 zipcrypto。

归档名去重

同名文件从不同目录加进来是很常见的(报告/data.csv备份/data.csv)。zip 内部只存 arcname,不去重就会产生两个同名条目——解压时后者覆盖前者,静默丢数据

used = set()
...
arcname = os.path.basename(path)
stem, ext = os.path.splitext(arcname)
n = 1
while arcname.lower() in used:
    arcname = f"{stem}({n}){ext}"
    n += 1
used.add(arcname.lower())

.lower() 比较是因为 windows 文件系统大小写不敏感,data.csvdata.csv 解压到同一目录还是会撞。重命名风格 data(1).csv 沿用 windows 的习惯,用户一看就懂。

四、不卡界面:工作线程 + 自定义 wx 事件

压缩 1gb 文件要几十秒。如果在主线程里干,窗口会白屏、标题栏变"(无响应)",windows 甚至会建议用户强制结束进程。

gui 编程的铁律:主线程只做界面,耗时操作一律扔出去;反过来,非主线程绝对不能碰任何控件。

wxpython 里跨线程通知 ui 的标准做法是自定义事件:

progressevent, evt_progress = wx.lib.newevent.newevent()
doneevent, evt_done = wx.lib.newevent.newevent()

newevent() 一次返回两个东西:事件类(用来构造)和事件绑定器(用来 bind)。工作线程只负责 wx.postevent

wx.postevent(self.sink, progressevent(current=i, total=total, name=arcname))

wx.postevent线程安全的——它把事件塞进主线程的事件队列,由主线程在下一轮事件循环里取出处理。传什么关键字参数,事件对象上就有什么属性,主线程直接 evt.current 取用。

主线程侧的绑定和处理干净得像同步代码:

self.bind(evt_progress, self.on_progress)
self.bind(evt_done, self.on_done)

def on_progress(self, evt):
    self.gauge.setvalue(evt.current)
    self.set_status(f"正在压缩 ({evt.current}/{evt.total}): {evt.name}")

顺带说一句 wx.callafter:它也线程安全,适合"随手调一下某个函数"。而自定义事件的优势是语义清晰、可被多处监听、参数结构化。任务型的通知(进度/完成)用事件,零散调用用 callafter

可取消,而且取消要"干净"

class zipworker(threading.thread):
    def __init__(self, sink, files, zip_path, password, nbits):
        super().__init__(daemon=true)
        ...
        self._cancel = threading.event()

    def cancel(self):
        self._cancel.set()

三个设计点:

1. daemon=true:用户点右上角关闭时,主线程退出后进程不会被这个后台线程吊住。

2. 用 threading.event 而不是布尔标志event 的读写有内存屏障保证,不依赖 gil 的实现细节。虽然 cpython 下布尔标志实际也能跑,但 event 是表达"我知道自己在写并发代码"的正确姿势。

3. 取消检查点放在循环开头,且要清理残骸

for i, path in enumerate(self.files, 1):
    if self._cancel.is_set():
        raise interruptederror("用户取消操作")
except interruptederror as e:
    self._cleanup()
    wx.postevent(self.sink, doneevent(ok=false, message=str(e), ...))

def _cleanup(self):
    try:
        if os.path.exists(self.zip_path):
            os.remove(self.zip_path)
    except oserror:
        pass

用异常而非 return 来实现取消,是为了复用 with 语句的退出逻辑。 with pyzipper.aeszipfile(...) 在异常穿出时会正常关闭文件句柄,然后 _cleanup() 才能删得掉这个文件——windows 上句柄没释放的文件是删不掉的(winerror 32)。顺序错了就会留下一个半成品 zip。

半成品 zip 必须删。留在磁盘上的话,用户会以为压缩成功了,去解压才发现文件不全——而那时原文件可能已经被"压缩后删除"功能清掉了。

异常信息要给人看,不是给栈看

except exception:
    self._cleanup()
    wx.postevent(self.sink, doneevent(
        ok=false,
        message=traceback.format_exc(limit=2).strip().splitlines()[-1],
        elapsed=time.time() - started))

format_exc(limit=2) 限制栈深度,取最后一行——也就是 permissionerror: [errno 13] permission denied: 'd:/x.zip' 这种真正有信息量的一行。既不会把三十行栈糊到用户脸上,也不会丢掉根因。

五、sqlite 双表设计与"加列即迁移"

一次压缩操作是"一条记录 + n 个文件",典型的一对多。硬塞进一张表的两种做法都不好:把文件列表 json 序列化成一个字段(没法查询、没法统计),或者一个文件一行、记录字段全部冗余(改一处要改 n 行)。

正经拆两张表:

create table if not exists zip_records (
    id          integer primary key autoincrement,
    zip_name    text    not null,
    target_dir  text    not null,
    zip_path    text    not null,
    file_count  integer not null default 0,
    source_size integer not null default 0,
    zip_size    integer not null default 0,
    encryption  text    not null,
    status      text    not null,
    message     text,
    elapsed     real,
    password    text,
    created_at  text    not null
);
create table if not exists zip_record_files (
    id        integer primary key autoincrement,
    record_id integer not null references zip_records(id) on delete cascade,
    file_path text    not null,
    file_size integer
);
create index if not exists idx_record_files on zip_record_files(record_id);

几个容易被忽略的点:

  • file_count 是刻意的冗余。列表页要显示文件数,如果每行都去 count(*) 子表,一百条记录就是一百次查询。写入时算一次存下来,读取零成本。
  • on delete cascade 必须配合 pragma。sqlite 的外键约束默认关闭,而且是"每个连接"级别的设置,不是数据库级别的:
def connect(self):
    conn = sqlite3.connect(self.path)
    conn.row_factory = sqlite3.row
    conn.execute("pragma foreign_keys = on")
    return conn

漏了这一行,你的 cascade 就是一句注释。这也是为什么每次连接都要重新执行——放在建表时执行一次是无效的

  • row_factory = sqlite3.row 让结果支持 row["zip_name"] 按名取值。用下标 row[2] 的代码,在你往 select 里加一个字段的那天就会集体错位,而且不报错。
  • created_at 存 text 而非 integer 时间戳。sqlite 无原生日期类型,存 "2026-08-03 14:30:00" 格式的字符串,字典序即时间序,order bylike '2026-08%' 都直接可用,导出 csv 给人看也不用转换。

加列迁移:给已经在用的数据库打补丁

"操作记录里加入密码"这个需求来得比数据库晚。用户手上已经有一个装着真实记录的 zip_records.dbcreate table if not exists 对已存在的表什么都不做,新字段永远加不上去。

cols = {r["name"] for r in conn.execute("pragma table_info(zip_records)")}
if "password" not in cols:      # 兼容旧版本已存在的数据库
    conn.execute("alter table zip_records add column password text")

pragma table_info(表名) 返回每一列的信息,取 name 组成集合,缺谁补谁。这个模式的价值:

  • 幂等:跑一百次和跑一次效果相同,可以无脑放在启动路径上;
  • 无损:老数据一条不动,新列在旧行上是 null
  • 零配置:不需要 alembic 那种迁移框架,也不需要维护版本号表。

对于单文件小工具,这是性价比最高的迁移方案。局限也要知道:sqlite 的 alter table 只能加列,不能改列类型、不能删列、不能加约束。真要改结构,得走"建新表 → 拷数据 → 删旧表 → 改名"的四步流程。

新列一定被追加到列顺序的末尾——这就是为什么第三节强调不能用下标访问结果集。

一个我留下的真实缺陷

def connect(self):
    conn = sqlite3.connect(self.path)
    ...
    return conn

def list_records(self, keyword=""):
    ...
    with self.connect() as conn:
        return conn.execute(sql, args).fetchall()

with sqlite3.connect(...) 管的是事务,不是连接。 它在退出时 commit()(或异常时 rollback()),但不会 close()。所以这里每调用一次就泄漏一个连接,靠垃圾回收兜底。

这个坑在开发时真实咬过我一次:测试脚本跑完想删掉临时 db 文件,permissionerror: [winerror 32] 另一个程序正在使用此文件。windows 上没关的文件句柄会锁住文件。

正确写法是嵌套 contextlib.closing,或者干脆持有一个长连接:

from contextlib import closing

with closing(self.connect()) as conn:
    with conn:                      # 事务
        return conn.execute(sql, args).fetchall()

我在这份代码里保留了原样,因为桌面工具的调用频次低、进程生命周期短,实际不会耗尽句柄。但如果你把这段代码搬到服务端或者高频循环里,它一定会炸。写出来,比藏起来有价值。

六、配置持久化:白名单才是安全阀

class config:
    defaults = {
        "files": [],
        "target_dir": "",
        "zip_name": "",
        "encryption": "aes-256 (最强)",
        "remember_password": false,
        "password": "",
        "window_size": [960, 700],
    }

    def load(self):
        try:
            with open(self.path, "r", encoding="utf-8") as f:
                saved = json.load(f)
            if isinstance(saved, dict):
                for k, v in saved.items():
                    if k in self.defaults:      # ← 关键在这一行
                        self.data[k] = v
        except (filenotfounderror, valueerror, oserror):
            pass

if k in self.defaults 这个白名单过滤是整个类的核心。 没有它,一个手工编辑过(或版本降级留下)的 config.json 就能往 self.data 里塞任意键;界面代码取到意外的键值,报的错会离现场很远,极难排查。有了它:

  • 老版本产生的、新版本已废弃的字段 → 自动忽略;
  • 新版本增加的字段 → 老配置里没有,自动取 defaults
  • 手改坏的、类型不对的键 → 至少限定在已知集合内。

isinstance(saved, dict) 那一层也不能省。config.json 里如果只有一个 []"abc"json.load 会成功返回,然后 .items()attributeerror ——而这个异常不在 except 列表里,程序直接崩在启动阶段,连界面都出不来。

异常一律吞掉是刻意的:配置读失败的正确降级是"用默认值启动",而不是弹窗拦住用户。第一次运行没有 config.json 更是完全正常的情况。

有意"不记住"的那一项

界面上有 6 个可交互设置项,但 defaults 里只有 5 个对应键——"压缩成功后删除原文件"故意没有配置键

这是一个刻意的产品决策:删原文件是不可逆操作(虽然进回收站,但用户不一定会去捡)。如果它被记住,用户上次为某个特殊场景勾了一次,下次打开程序时它还亮着,而用户完全不记得——批量文件就这么没了。

破坏性操作的默认状态必须是关闭,而且不能被持久化。 每次都要用户重新做一次决定,这点"不方便"是设计的一部分,不是遗漏。同理它在界面上是红色的:

self.chk_delete = wx.checkbox(box_opt, label="压缩成功后删除原文件(移入回收站)")
self.chk_delete.setforegroundcolour(wx.colour(0xc0, 0x39, 0x2b))

而且真正执行前还有一次二次确认:

if self.chk_delete.getvalue() and wx.messagebox(
        f"压缩成功后将把这 {len(self.files)} 个原文件移入回收站。\n"
        f"确定继续吗?", app_name,
        wx.yes_no | wx.icon_exclamation) != wx.yes:
    return

配置里的失效路径

def apply_config(self):
    self.add_files([p for p in self.cfg["files"] if os.path.isfile(p)], silent=true)

上次记住的文件,这次可能已经被移动、重命名或删除了。启动时用 os.path.isfile 过一遍,避免列表里躺着一堆点了就报错的幽灵条目。silent=true 是不让恢复配置的过程去刷"已添加 n 个文件"的状态栏——那句话应该只在用户真的做了添加动作时出现。

七、ui 层的两个反直觉修复

windows 上原生按钮会无视你的背景色

想做一个蓝色强调按钮,直觉写法:

btn = wx.button(panel, label="开始生成")
btn.setbackgroundcolour(wx.colour(0x2d, 0x6c, 0xdf))   # ❌ windows 上毫无效果

代码不报错,方法调用成功,按钮依然是系统灰

原因:windows vista 之后,按钮由**主题引擎(uxtheme)**绘制,整个背景是一张主题位图。setbackgroundcolour 设置的是控件背景色属性,而主题绘制过程根本不读这个属性。这不是 wxpython 的 bug,是原生控件的既定行为——在 gtk 上同一行代码是生效的,所以这个坑只在 windows 上出现。

解法是换成自绘按钮wx.lib.buttons.genbutton 是纯 python 用 dc 画出来的按钮,它自己负责所有像素,所以颜色说了算:

class flatbutton(wx.lib.buttons.genbutton):
    """扁平强调色按钮。windows 主题下原生 wx.button 会忽略 setbackgroundcolour,故改用自绘按钮。"""

    disabled = wx.colour(0xc4, 0xc9, 0xd2)

    def __init__(self, parent, label, color, hover, size=wx.defaultsize):
        super().__init__(parent, label=label, size=size, style=wx.border_none)
        self._base, self._hover = color, hover
        self.setbezelwidth(0)              # 去掉 3d 立体边,才是"扁平"
        self.setusefocusindicator(false)   # 去掉焦点虚线框
        self.setbackgroundcolour(color)
        self.setforegroundcolour(wx.white)
        f = self.getfont()
        f.setweight(wx.fontweight_bold)
        self.setfont(f)
        self.setcursor(wx.cursor(wx.cursor_hand))
        self.bind(wx.evt_enter_window, lambda e: self._tint(self._hover))
        self.bind(wx.evt_leave_window, lambda e: self._tint(self._base))

    def _tint(self, colour):
        if self.isenabled():
            self.setbackgroundcolour(colour)
            self.refresh()

    def enable(self, enable=true):
        ret = super().enable(enable)
        self.setbackgroundcolour(self._base if enable else self.disabled)
        self.refresh()
        return ret

自绘的代价是所有视觉状态都得自己管genbutton 不会因为你 enable(false) 就自动变灰,所以要重写 enable,在里面手动改色并 refresh()。悬停高亮同理,靠 evt_enter_window / evt_leave_window 自己切。忘了 refresh() 的话颜色属性改了但屏幕不重绘,要等到窗口被遮挡再露出才更新——一个非常迷惑的"偶发 bug"。

只有需要强调色的两个按钮(生成、取消)用 flatbutton,其余保持原生 wx.button混用是有意的:次要按钮跟随系统主题,视觉层级自然分明,也少写代码。

勾选"显示密码"时,输入框会跳位

用户报的现象很具体:最大化窗口后勾选"显示密码",输入框会移动位置。

有问题的常规思路是:切换时销毁原控件,用新的样式重建。

# ❌ 这会导致布局跳动
self.txt_pwd.destroy()
self.txt_pwd = wx.textctrl(parent, style=0 if show else wx.te_password)

wx.te_password 这个样式位只能在创建时指定,运行时改不了,所以"销毁重建"看似是唯一出路。但新建的控件是被 append 到 sizer 末尾的,在 flexgridsizer 里就是掉到了另一个格子;而且新控件的 best size 与原来未必一致,一整行的高度都可能变。窗口越宽(最大化时),这个错位越明显。

解法:两个控件都建好,叠在一起,只切换谁可见。

class passwordctrl(wx.panel):
    """密码输入框。掩码/明文两个控件叠放并切换显示,避免切换时重建控件导致布局跳动。"""

    def __init__(self, parent):
        super().__init__(parent)
        self.setbackgroundcolour(parent.getbackgroundcolour())
        self.masked = wx.textctrl(self, style=wx.te_password)
        self.plain = wx.textctrl(self)
        self.plain.hide()
        s = wx.boxsizer(wx.vertical)
        s.add(self.masked, 0, wx.expand)
        s.add(self.plain, 0, wx.expand)
        self.setsizer(s)

    def getvalue(self):
        return (self.plain if self.plain.isshown() else self.masked).getvalue()

    def setvalue(self, value):
        self.masked.setvalue(value)
        self.plain.setvalue(value)

    def show_text(self, show):
        value = self.getvalue()
        self.plain.show(show)
        self.masked.show(not show)
        self.setvalue(value)
        self.layout()

关键在于 hide() 的控件不占 sizer 空间,所以垂直 boxsizer 里两个控件的实际效果是"同一位置二选一"。外层 passwordctrl 作为一个 wx.panel,从父布局的角度看尺寸和位置永远不变——父级 sizer 根本不知道里面发生了切换,自然不会重排。

show_text 里先取值、切换、再写回,是因为两个 textctrl 各有独立的内容缓冲。用户在掩码框里输入的字符不会自动出现在明文框里,不同步就会"一勾选密码就没了"。

顺带一提,这个封装还带来一个额外好处:getvalue / setvalue 的签名和 wx.textctrl 一致,调用方(validate()save_config())完全不需要知道内部有两个控件。

八、图片预览:闭包懒加载 + 解码器兜底

需求是:在操作记录里选一条,预览其中的图片。设计上有三个决策。

决策一:从 zip 内解密读,而不是读磁盘原文件

原文件可能已经被"压缩后删除"清掉了,也可能被用户移走。zip 才是那条记录的权威内容。所以主路径是拿记录里存的密码去解密 zip,只在 zip 不可用时回退到磁盘:

def image_items(self, row):
    """收集可预览的图片,返回 (zip 句柄, [(名称, 读字节函数)], 来源说明)。

    优先用记录里的密码从 zip 内解密读取;zip 缺失或密码不可用时回退到磁盘上的原文件。
    """
    pwd = deobfuscate(row["password"])
    zip_path = row["zip_path"] or ""
    if os.path.isfile(zip_path):
        zf = none
        try:
            zf = pyzipper.aeszipfile(zip_path)
            if pwd:
                zf.setpassword(pwd.encode("utf-8"))
            names = sorted(n for n in zf.namelist() if is_image(n))
            if names:
                zf.open(names[0]).close()   # 先探一次,密码不对就走回退分支
                return zf, [(n, lambda n=n: zf.read(n)) for n in names], "zip 内解密"
        except exception:
            pass
        if zf:
            zf.close()
    items = [(os.path.basename(f["file_path"]), f["file_path"])
             for f in self.db.record_files(row["id"])
             if is_image(f["file_path"]) and os.path.isfile(f["file_path"])]
    return none, [(n, lambda p=p: read_file(p)) for n, p in items], "磁盘原文件"

zf.open(names[0]).close() 这一行是探测:密码错误时 open 会立刻抛异常,此时还来得及走回退分支。如果不探测,等到用户点开预览窗口才发现每张图都读不出来,体验就差了一截。

来源 字符串会显示在预览窗标题栏上(“来源: zip 内解密” / “来源: 磁盘原文件”)。告诉用户他正在看的是哪份数据,这在两份数据可能不一致时很重要。

决策二:闭包懒加载,别一次解密整包

用户的压缩包可能有几十张 1024×1536 的图。一次性全解密、全解码,内存和等待时间都不可接受。

所以列表里存的不是数据,而是取数据的函数

[(n, lambda n=n: zf.read(n)) for n in names]

previewdialog 只在切到某一张时才调用它:

def load(self):
    name, read = self.items[self.index]
    self.image, self.cache, err = none, (none, none), ""
    try:
        self.image, err = decode_image(read())     # ← 此刻才真正读取
    except exception as e:
        err = str(e) or e.__class__.__name__

这里的 lambda n=n: 不是多余的,是躲一个经典的 python 陷阱。

# ❌ 错误示范
fns = [lambda: zf.read(n) for n in names]
fns[0]()     # 读的是 names[-1],不是 names[0]

python 闭包捕获的是变量本身(late binding),不是变量当时的值。循环结束后 n 停在最后一个元素上,所有 lambda 都会读同一个文件。用 n=n 把当前值绑成默认参数,就在函数定义时"快照"下来了。同样的技巧在下一行的 lambda p=p: read_file(p) 里再用了一次。

这个 bug 极其阴险:图片列表长度、文件名显示全都正常,只是每一张显示的都是最后一张的内容。如果你的测试数据恰好是两张相似的图,很可能看不出来。

决策三(也是用户实际报的 bug):wxwidgets 不认 webp

功能写完自测通过,交付。用户回了三个字:“预览不成功”

没有错误信息、没有操作步骤。这时候最快的路径不是追问,而是直接看真实数据:读 zip_records.db 里的实际记录,打开用户真实的那个 zip。

结果一目了然——压缩包里 8 个文件全是 .webp(ai 生成的图片批次,文件名形如 assets_task_01jxy..._img_1.webp)。而我自己写测试时造的 fixture 全是 png。

验证根因:

>>> img = wx.image(io.bytesio(webp_bytes))
>>> img.isok()
false
>>> wx.bitmap_type_webp
attributeerror: module 'wx' has no attribute 'bitmap_type_webp'

wxwidgets 3.2.8 里没有 webp 解码器。 webp 支持是 wxwidgets 3.3 才加进去的,当前 wxpython 4.2.4 绑的还是 3.2.x。所以每一张图都显示"无法识别的图片格式",功能等于不存在。

解法:让 pillow 兜底。

try:                              # wxwidgets 3.2 不带 webp 解码器,用 pillow 兜底
    from pil import image as pilimage, imageops
except importerror:
    pilimage = none
def decode_image(data):
    """把图片字节解成 wx.image,失败时返回 (none, 原因)。

    wx 自带解码器不认的格式(webp 最常见)交给 pillow,顺带按 exif 摆正方向。
    """
    with wx.lognull():                    # 解析失败时不弹 wx 自带的错误框
        img = wx.image(io.bytesio(data))
    if img.isok():
        return img, ""
    if pilimage is none:
        return none, "格式不支持,安装 pillow 后可预览(pip install pillow)"
    try:
        with pilimage.open(io.bytesio(data)) as pil:
            rgba = imageops.exif_transpose(pil).convert("rgba")
        img = wx.image(rgba.width, rgba.height)
        raw = rgba.tobytes()
        img.setdata(rgba.convert("rgb").tobytes())
        img.setalpha(raw[3::4])
        return img, ""
    except pilimage.unidentifiedimageerror:
        return none, "无法识别的图片格式"
    except exception as e:
        return none, f"无法解码: {e or e.__class__.__name__}"

拆开看每个细节:

  • with wx.lognull()wx.image 解码失败会自己弹一个错误对话框。lognull 在作用域内屏蔽 wx 的日志目标,让我们能安静地"试一下"再决定怎么处理。没有它,用户会先吃一个丑陋的系统弹窗。
  • 先试 wx 再试 pillow:wx 走的是 c++ 原生解码,png/jpg 这些常见格式更快,也不引入额外依赖。pillow 只是兜底。
  • imageops.exif_transpose:手机拍的照片方向信息在 exif 里,不处理会横躺。wx 原生解码不管这个,用 pillow 时顺手做掉。
  • pillow → wx.image 的通道拆分:这是最容易写错的地方。wx.image 把 rgb 和 alpha 分开存(setdata 收 3 字节/像素,setalpha 收 1 字节/像素),而 pillow 的 rgba tobytes() 是 4 字节交错的。所以 raw[3::4] 用切片步长 4 从偏移 3 开始取,正好抽出所有 alpha 字节。rgb 部分则用 convert("rgb").tobytes() 让 pillow 自己去掉 alpha 通道——比手写切片拼接更不容易错。
  • unidentifiedimageerror 单独捕获:不加这一层的话,用户会在界面上看到 无法解码: cannot identify image file <_io.bytesio object at 0x000001f2...>。python 的异常 repr 泄漏到 ui 上是很不专业的,单独捕获这个最常见的异常,换成一句人话。
  • e or e.__class__.__name__:某些异常的 str() 是空字符串,那样会显示成 无法解码: 。退化到类名至少还有信息。

pillow 是可选依赖try/except importerror),没装时程序照常运行,只是遇到 webp 会提示"安装 pillow 后可预览"。但打包 exe 时不能漏——用户 100% 会走到这条路径。

修复后用用户真实的那个压缩包验证:8/8 全部解码成功,逐像素与 pillow 参考结果一致,hasalpha() 为 true,converttobitmap() 正常。

画布:只缩不放 + 缓存缩放结果

def on_paint(self, _):
    dc = wx.autobufferedpaintdc(self.canvas)
    dc.setbackground(wx.brush(self.bg))
    dc.clear()
    cw, ch = self.canvas.getclientsize()
    if not self.image or cw < 4 or ch < 4:
        return
    iw, ih = self.image.getwidth(), self.image.getheight()
    scale = min(cw / iw, ch / ih, 1.0)      # 只缩不放,避免拉伸模糊
    w, h = max(1, int(iw * scale)), max(1, int(ih * scale))
    if self.cache[0] != (w, h):
        img = self.image if (w, h) == (iw, ih) else self.image.scale(
            w, h, wx.image_quality_high)
        self.cache = ((w, h), img.converttobitmap())
    dc.drawbitmap(self.cache[1], (cw - w) // 2, (ch - h) // 2, true)
  • wx.autobufferedpaintdc + setbackgroundstyle(wx.bg_style_paint) 是消除闪烁的标准组合。先在内存位图上画完再一次性贴到屏幕,避免用户看到"清背景→画图"的中间态。bg_style_paint 告诉 wx"背景我自己画,你别擦"。
  • min(..., 1.0) 里的 1.0 意思是小图保持原始尺寸,不放大。放大只会得到一张模糊的图,不如让用户看清 真实像素。
  • max(1, ...) 防御极端窄窗口下算出 0 宽度——scale(0, h) 会抛异常。
  • 缓存键是目标尺寸 (w, h)。窗口拖动时 evt_size 会高频触发重绘,如果每次都 scale(...image_quality_high),1024×1536 的图会明显卡顿。尺寸没变就复用上次的 bitmap。
  • drawbitmap(..., true) 最后那个参数是 usemask,让 alpha 通道生效,透明 png 才不会显示成黑块。
  • (cw - w) // 2 让图片在画布中居中。

键盘导航用 evt_char_hook 而不是 evt_key_down

self.bind(wx.evt_char_hook, self.on_key)

def on_key(self, evt):
    key = evt.getkeycode()
    if key in (wx.wxk_left, wx.wxk_up, wx.wxk_pageup):
        self.step(-1)
    elif key in (wx.wxk_right, wx.wxk_down, wx.wxk_pagedown, wx.wxk_space):
        self.step(1)
    else:
        evt.skip()

evt_char_hook 绑在 dialog 上,在按键被分派给具体子控件之前就能拿到。用 evt_key_down 的话,焦点在"下一张"按钮上时,方向键会被按钮自己吃掉去做焦点导航。else: evt.skip() 必须留着——不然 tab、esc 等键全部失灵。

step 用取模实现循环翻页,最后一张的下一张回到第一张:

def step(self, delta):
    if len(self.items) > 1:
        self.index = (self.index + delta) % len(self.items)
        self.load()

九、删原文件:用 ctypes 调 windows 回收站

"压缩成功后删除原文件"这个需求,os.remove不能用的——它是彻底删除,误操作无法挽回。正确做法是走系统回收站,用户还有一次后悔的机会。

python 标准库没有回收站 api(send2trash 是第三方库)。为了不增加依赖,用 ctypes 直接调 win32 的 shfileoperationw

def move_to_trash(paths):
    """把文件移入回收站,返回 (成功数, [(路径, 失败原因)])。

    windows 走 shfileoperationw + fof_allowundo,误删可从回收站还原;
    其他平台没有统一的回收站 api,退化为直接删除。
    """
    paths = [os.path.abspath(p) for p in paths if os.path.isfile(p)]
    if not paths:
        return 0, []
    if sys.platform != "win32":
        ok, failed = 0, []
        for p in paths:
            try:
                os.remove(p)
                ok += 1
            except oserror as e:
                failed.append((p, str(e)))
        return ok, failed

    from ctypes import wintypes

    class shfileopstructw(ctypes.structure):
        _fields_ = [
            ("hwnd", wintypes.hwnd),
            ("wfunc", wintypes.uint),
            ("pfrom", wintypes.lpcwstr),
            ("pto", wintypes.lpcwstr),
            ("fflags", ctypes.c_uint),
            ("fanyoperationsaborted", wintypes.bool),
            ("hnamemappings", ctypes.c_void_p),
            ("lpszprogresstitle", wintypes.lpcwstr),
        ]

    fo_delete = 3
    fof_silent, fof_noconfirmation, fof_allowundo, fof_noerrorui = 0x04, 0x10, 0x40, 0x400

    op = shfileopstructw()
    op.wfunc = fo_delete
    # pfrom 是双 \0 结尾的路径串
    op.pfrom = "\0".join(paths) + "\0\0"
    op.fflags = fof_allowundo | fof_noconfirmation | fof_silent | fof_noerrorui
    ret = ctypes.windll.shell32.shfileoperationw(ctypes.byref(op))
    if ret != 0:
        return 0, [(p, f"shfileoperation 错误码 {ret}") for p in paths]
    if op.fanyoperationsaborted:
        return 0, [(p, "操作被中止") for p in paths]
    remaining = [p for p in paths if os.path.isfile(p)]
    return len(paths) - len(remaining), [(p, "仍然存在") for p in remaining]

几个必须踩准的细节:

  • fof_allowundo 是"进回收站"的开关。漏了这个标志,fo_delete 就是永久删除。整个函数的价值全押在这一个位上。
  • pfrom 必须是双 \0 结尾的字符串。这是 win32 表达"字符串数组"的老式约定:路径之间用 \0 分隔,整个列表再以一个额外的 \0 收尾。只写一个 \0 会导致最后一个路径被截断或读到越界内存。这也是为什么能一次删多个文件而只调一次 api。
  • 结构体字段顺序和类型必须与头文件严格一致。ctypes.structure 是按 _fields_ 顺序做内存布局的,错一个字段类型就是内存错位,轻则参数乱掉,重则崩溃。
  • shfileoperationw 的返回值不是布尔,0 才是成功,非 0 是错误码。而且成功返回 0 不代表文件真的删了——所以后面还要 fanyoperationsabortedos.path.isfile 两道复查。api 说成功、文件还在的情况真实存在(比如被其他进程占用)。
  • from ctypes import wintypes 放在函数内部。wintypes 在非 windows 平台上导入会失败,放在模块顶层会让整个程序在 linux/macos 上直接 import 不进来。放在 win32 分支之后,非 windows 平台永远不会执行到。
  • 返回 (成功数, 失败清单) 而不是布尔。批量操作的结果天然是部分成功的,调用方需要知道具体哪些失败、为什么失败,才能给用户有用的提示。

调用侧还要同步界面状态——删掉的文件不能继续留在"待压缩文件"列表里:

def trash_sources(self, info):
    """把已成功压缩的原文件移入回收站,并从待压缩列表中同步移除。"""
    ok, failed = move_to_trash(info["files"])
    gone = {p for p in info["files"] if not os.path.isfile(p)}
    for i in range(len(self.files) - 1, -1, -1):
        if self.files[i] in gone:
            self.list.deleteitem(i)
            del self.files[i]
    self.refresh_count()
    if failed:
        wx.messagebox("以下原文件未能删除:\n" +
                      "\n".join(f"{p}\n    {why}" for p, why in failed[:5]),
                      app_name, wx.icon_warning)
    return f" · 已将 {ok} 个原文件移入回收站" if ok else ""

range(len - 1, -1, -1) 倒序遍历删除是必须的。正序删除时,deleteitem(3) 之后原来的第 4 项变成了第 3 项,索引全部错位,会漏删或删错。倒序遍历时被删项之后的索引变化不影响还没遍历到的前面部分。

判断"删掉了哪些"用 os.path.isfile 复查,而不是信 move_to_trash 的成功数。成功数只是个计数,不告诉你具体是哪几个成功。以磁盘实际状态为准最可靠。

failed[:5] 限制弹窗只列 5 条——一次删 200 个文件全失败的话,弹窗会长到超出屏幕,用户连"确定"按钮都点不到。

十、踩坑合集

把前面散落的坑和几个额外的集中列一遍,方便速查。

#现象根因 / 解法
1__file__ 定位配置打包后配置每次都丢onefile 的 __file__ 在临时解包目录,进程退出即删。改用 sys.frozen + sys.executable
2zipfile.setpassword 写入生成的 zip 毫无加密,且不报错标准库 setpassword 只用于解密。改用 pyzipper.aeszipfile
3windows 按钮改色setbackgroundcolour 静默无效uxtheme 主题绘制不读背景色属性。改用自绘 genbutton,并自己管 disabled/hover
4切换密码可见性最大化后输入框跳位te_password 只能创建时指定,销毁重建打乱 sizer。改为两控件叠放 show/hide
5webp 预览全部显示"无法识别的图片格式"wxwidgets 3.2.8 无 webp 解码器,无 wx.bitmap_type_webp。pillow 兜底
6循环里建 lambda每张图都显示最后一张的内容python 闭包 late binding。用 lambda n=n: 绑定默认参数快照
7with sqlite3.connect()删 db 文件报 winerror 32该上下文管理器只管事务不 close 连接。需嵌套 contextlib.closing

另外三个在写测试时才暴露的:

8. pillow 存 webp 默认是有损的。 造测试 fixture 时用 image.new(...).save("x.webp"),然后断言解码出来的像素是 (30, 90, 200)——实际拿到 (31, 90, 202)。差了 1~2 个色阶,看起来像解码器串色 bug,其实是 webp 有损压缩的正常损失。做像素级断言的 fixture 必须 save(path, lossless=true)

9. wx.screendc 截图不可靠。 想用截图验证界面渲染,blit 出来的图大面积空白带斜向撕裂,大概率跟 dpi 缩放和桌面合成(dwm)有关。gui 的自动化验证不要依赖截图,改成程序化断言更稳:用 getred/getgreen/getblue 抽查像素、用 bitmap.isok() 确认位图已生成、用矩形相交判断控件有没有重叠。

10. py_compile.compile(..., cfile=os.devnull) 在 windows 上会炸。 报 fileexistserror: nul is a non-regular file。windows 的 nul 设备不是常规文件,写不进去。老实编译到临时文件再删掉。

十一、必须说清楚的安全性问题

这一节比前面所有功能都重要。

代码里有一对函数:

def obfuscate(text):
    """base64 混淆存储的密码。仅防止肉眼直读,不是加密。"""
    return base64.b64encode(text.encode("utf-8")).decode("ascii") if text else ""


def deobfuscate(text):
    if not text:
        return ""
    try:
        return base64.b64decode(text.encode("ascii")).decode("utf-8")
    except (valueerror, unicodedecodeerror):
        return ""

base64 不是加密,是编码。 它没有密钥,任何人拿到密文就能还原明文——一行 python、甚至一个在线工具就够了。

>>> base64.b64decode("mtizndu2").decode()
'123456'

它在这里的作用只有一个:防止密码在文本编辑器里被瞄到。用户打开 config.json 或用 db 工具浏览 zip_records.db 时,看到的是 mtizndu2 而不是 123456,不会被路过的同事一眼记住。

它挡不住的:

  • 任何人拿到 zip_records.db 文件 → 所有历史密码明文可得
  • 任何人拿到 config.json(且用户勾了"记住密码")→ 当前密码明文可得
  • 恶意软件扫描用户目录 → 同上。

所以界面上的复选框文案是 “记住密码(明文风险,仅本机)” ——直接把风险写在标签上,而不是藏在文档里。而且第六节提到的:不勾"记住密码"时,config.save() 会主动把这个字段写空,不留残留:

def save(self):
    out = dict(self.data)
    if out["remember_password"] and out["password"]:
        out["password"] = obfuscate(out["password"])
    else:
        out["password"] = ""          # 不记住就清空,不留残留

为什么不做真加密

因为在这个场景下做不到。要加密就要有密钥,密钥要么:

  • 也存在本地 → 攻击者拿到程序就拿到密钥,等于没加密(这是所谓 “security through obscurity”);
  • 让用户每次输主密码 → 那用户不如直接记住 zip 密码,功能失去意义。

这是密码管理器要解决的问题,不是一个压缩工具该解决的问题。

如果你要把这段代码用在多人共用的机器、或有合规要求的环境,正确做法是:

  1. 根本不存密码。历史记录里只记"用过密码"这个事实。代价是预览功能要让用户手动输密码。
  2. 用 windows dpapicryptprotectdata)。它用当前用户的登录凭据派生密钥,密文只有同一 windows 账户能解开,拷到别的机器上是废数据。这是 windows 上唯一"不用管密钥"的正经方案。
  3. 接系统凭据管理器keyring 库)。密码存进 windows 凭据保管库 / macos keychain,程序只存一个引用。

顺便说清楚:zip 文件本身的 aes-256 加密是真加密,是可靠的。 本节讨论的风险只涉及"程序为了方便帮你记下的那份密码副本"。压缩包本身拿到任何机器上,没有密码都是打不开的。

十二、已知短板与改进方向

一份诚实的自我检查,也是这份代码最有参考价值的部分。

1. 数据库连接不 close(第五节详述)。 桌面工具低频调用不致命,搬到高频场景必炸。修复:contextlib.closing

2. 删原文件前没有回读校验。 trash_sources 只信 evt.ok(压缩线程没抛异常)就动手删。更稳的做法是先把 zip 完整读一遍再删:

with pyzipper.aeszipfile(zip_path) as zf:
    zf.setpassword(pwd.encode("utf-8"))
    bad = zf.testzip()                      # 校验所有条目的 crc
    assert bad is none

在执行不可逆操作之前,验证可逆的那一半确实成功了——这是删除类功能的基本原则。当前实现只做到了"进回收站"这一层兜底。

3. zf.read() 把整张图一次性读进内存。 单张 1024×1536 的 webp 不到 1mb 无所谓,但如果压缩包里是 100mb 的 tiff,切一张就吃 100mb。改进方向是 zf.open() 流式读,配合 pillow 的 draft() 做降采样解码。

4. previewdialog 的闭包捕获了 zf,而 on_previewfinally 会关掉它。

zf, items, note = self.image_items(r)
try:
    ...
    with previewdialog(self, r["zip_name"], items, note) as dlg:
        dlg.showmodal()
finally:
    if zf:
        zf.close()

这段**只因为对话框是模态的(showmodal 阻塞)**才安全——finally 执行时对话框已经销毁,没人再用那些闭包了。哪天有人把它改成 show() 非模态,闭包就会去读一个已关闭的 zipfile,抛 valueerror: seek of closed file。这是一个隐式的耦合,应该用注释标注,或者干脆让 previewdialog 自己持有并负责关闭 zf

5. 进度粒度是"文件级"的。 压 1000 个小文件进度条很流畅,压 1 个 5gb 的文件则会从 0% 直接跳到 100%,中间毫无反馈。要做字节级进度,得绕过 zf.write() 自己分块喂 zf.open(name, "w")

6. 大量文件时 listctrl 会慢。insertitem 一次就触发一次重绘。加 freeze() / thaw() 包住批量插入可以显著改善;上万条则应该换 lc_virtual 虚拟列表模式。

7. 没有"只压缩不带路径"之外的选项。 现在一律用 os.path.basename 平铺,无法保留目录结构。加个"保留相对路径"的选项并不难,但要处理跨盘符时公共前缀不存在的情况。

十三、打包与运行

# 安装依赖
pip install wxpython pyzipper pillow
# 直接运行
python zip_maker.py
# 打包(-w 不显示控制台,-f 单文件)
pyinstaller -w -f zip_maker.py

打包后 dist/zip_maker.exe 可以随意搬动,config.jsonzip_records.db 会在 exe 同目录生成——这正是第二节那 6 行代码保证的。

最后一个 windows 小细节,在 __main__ 里:

if __name__ == "__main__":
    if sys.platform == "win32":
        try:
            import ctypes
            ctypes.windll.shcore.setprocessdpiawareness(1)
        except exception:
            pass
    app(false).mainloop()

不声明 dpi 感知,windows 会把窗口按系统缩放比例做位图拉伸,在 150%/200% 缩放的高分屏上,整个界面的文字会有一层明显的模糊。setprocessdpiawareness(1) 告诉系统"我自己按物理像素画",字体就锐利了。

必须包在 try/except 里:shcore.dll 是 windows 8.1 才有的,更老的系统上会 attributeerror。而且这个调用必须在创建任何窗口之前执行——放到 app.oninit 里就晚了。

app(false)falseredirect 参数,意思是不要把 stdout/stderr 重定向到一个弹出窗口。默认行为在打包后会让每个 print 都弹一个框,非常烦人。

结语

这个工具最终 1106 行,功能清单不长:多选文件、aes 加密压缩、操作落库、配置回填、历史查询、图片预览、原文件回收。但里面有价值的东西不在功能上,而在那些"看起来该这么写、实际不能这么写"的地方:

  • 打包后的路径语义,__file__ / sys.executable / sys._meipass 三者的分工;
  • 标准库 zipfile 加密支持的静默失败——最危险的 bug 是不报错的那种;
  • 原生控件在不同平台的脾气(setbackgroundcolour 在 gtk 生效、在 windows 不生效);
  • python 闭包的 late binding,在循环里造函数时永远要想一次;
  • 自造的测试数据和用户真实数据的分布差异——png 全绿、webp 全红,这是整个项目里最贵的一课。

最后那条值得单独强调。功能上线前我跑了 36 项断言全部通过,用户只回了三个字"预览不成功"。测试通过和功能可用之间的距离,就是我的 fixture 和用户真实文件之间的距离。

以上就是python+wxpython从零编写一个aes加 zip工具(附源码)的详细内容,更多关于python加密zip的资料请关注代码网其它相关文章!

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com