python

关注公众号 jb51net

关闭
首页 > 脚本专栏 > python > Python加密zip

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

作者:winfredzhang

本文介绍了一个Python加密ZIP文件工具的开发过程,文内详细记录了从技术选型到打包上线的完整过程,涵盖AES加密实现、配置文件定位、UI不卡顿处理等实战技巧,希望对大家有所帮助

一、需求与技术选型

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

需求决策点
选择多个文件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?

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

二、第一个坑:打包成 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);

几个容易被忽略的点:

def connect(self):
    conn = sqlite3.connect(self.path)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA foreign_keys = ON")
    return conn

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

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

"操作记录里加入密码"这个需求来得比数据库晚。用户手上已经有一个装着真实记录的 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 组成集合,缺谁补谁。这个模式的价值:

对于单文件小工具,这是性价比最高的迁移方案。局限也要知道: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 里塞任意键;界面代码取到意外的键值,报的错会离现场很远,极难排查。有了它:

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__}"

拆开看每个细节:

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)

键盘导航用 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]

几个必须踩准的细节:

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

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,不会被路过的同事一眼记住。

它挡不住的:

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

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

为什么不做真加密

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

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

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

  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 加密压缩、操作落库、配置回填、历史查询、图片预览、原文件回收。但里面有价值的东西不在功能上,而在那些"看起来该这么写、实际不能这么写"的地方:

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

以上就是Python+wxPython从零编写一个AES加 ZIP工具(附源码)的详细内容,更多关于Python加密zip的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文