构建更安全的 PyQt5 文本编辑器:打开、原子保存与未保存提醒

2019 年原始导出只有 front matter,没有文章正文。后来的维护曾加入一个最小编辑器;本次 2026 版仍保持示例可学习,但不会在保存成功前截断旧文件,也不会无提示丢弃修改。

这个示例保证什么

程序会:

  • 明确把文件当作 UTF-8 文本;解码失败时报告错误,而不是猜测;
  • 在取消“打开”或读取失败时保持当前文档不变;
  • 跟踪 QTextDocument 的修改状态;
  • 在“新建”“打开”或“关闭”将丢失编辑前,提供“保存”“丢弃”“取消”;
  • 使用 QSaveFile,完整写入成功后才把临时文件提交为目标文件;
  • 让保存方法返回布尔结果,因此取消“另存为”也会取消请求它的破坏性动作。

它是教学用编辑器,不是成熟代码编辑器的替代品。限制在后文明确列出。

完整示例

import sys
from pathlib import Path
from typing import Optional

from PyQt5.QtCore import QIODevice, QSaveFile
from PyQt5.QtGui import QCloseEvent, QKeySequence
from PyQt5.QtWidgets import (
    QAction,
    QApplication,
    QFileDialog,
    QMainWindow,
    QMessageBox,
    QTextEdit,
)


class TextEditor(QMainWindow):
    def __init__(self):
        super().__init__()

        self.current_path = None  # type: Optional[Path]
        self.editor = QTextEdit()
        self.editor.setAcceptRichText(False)
        self.setCentralWidget(self.editor)
        self.editor.document().modificationChanged.connect(self.update_title)

        self.build_menu()
        self.resize(800, 600)
        self.update_title()

    def make_action(self, text, shortcut, slot):
        action = QAction(text, self)
        action.setShortcut(shortcut)
        action.triggered.connect(slot)
        return action

    def build_menu(self):
        file_menu = self.menuBar().addMenu("&File")
        file_menu.addAction(
            self.make_action("&New", QKeySequence.New, self.new_file)
        )
        file_menu.addAction(
            self.make_action("&Open…", QKeySequence.Open, self.open_file)
        )
        file_menu.addAction(
            self.make_action("&Save", QKeySequence.Save, self.save_file)
        )
        file_menu.addAction(
            self.make_action("Save &As…", QKeySequence.SaveAs, self.save_file_as)
        )
        file_menu.addSeparator()
        file_menu.addAction(
            self.make_action("E&xit", QKeySequence.Quit, self.close)
        )

    def update_title(self):
        name = self.current_path.name if self.current_path else "Untitled"
        marker = "*" if self.editor.document().isModified() else ""
        self.setWindowTitle(f"{marker}{name} — PyQt5 Text Editor")

    def new_file(self):
        if not self.maybe_save():
            return
        self.editor.clear()
        self.current_path = None
        self.editor.document().setModified(False)
        self.update_title()

    def open_file(self):
        if not self.maybe_save():
            return

        file_name, _ = QFileDialog.getOpenFileName(
            self,
            "Open UTF-8 Text File",
            "",
            "Text Files (*.txt *.md *.py);;All Files (*)",
        )
        if not file_name:
            return

        path = Path(file_name)
        try:
            text = path.read_text(encoding="utf-8-sig")
        except UnicodeError as error:
            self.show_error("Open failed", f"The file is not valid UTF-8:n{error}")
            return
        except OSError as error:
            self.show_error("Open failed", str(error))
            return

        self.editor.setPlainText(text)
        self.current_path = path
        self.editor.document().setModified(False)
        self.update_title()

    def save_file(self):
        if self.current_path is None:
            return self.save_file_as()
        return self.write_file(self.current_path)

    def save_file_as(self):
        suggestion = str(self.current_path) if self.current_path else "untitled.txt"
        file_name, _ = QFileDialog.getSaveFileName(
            self,
            "Save UTF-8 Text File",
            suggestion,
            "Text Files (*.txt);;Markdown Files (*.md);;Python Files (*.py);;All Files (*)",
        )
        if not file_name:
            return False
        return self.write_file(Path(file_name))

    def write_file(self, path):
        payload = self.editor.toPlainText().encode("utf-8")
        output = QSaveFile(str(path))

        if not output.open(QIODevice.WriteOnly):
            self.show_error("Save failed", output.errorString())
            return False

        if output.write(payload) != len(payload):
            message = output.errorString()
            output.cancelWriting()
            self.show_error("Save failed", message)
            return False

        if not output.commit():
            self.show_error("Save failed", output.errorString())
            return False

        self.current_path = path
        self.editor.document().setModified(False)
        self.update_title()
        return True

    def maybe_save(self):
        if not self.editor.document().isModified():
            return True

        choice = QMessageBox.warning(
            self,
            "Unsaved changes",
            "Save changes before continuing?",
            QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel,
            QMessageBox.Save,
        )
        if choice == QMessageBox.Save:
            return self.save_file()
        return choice == QMessageBox.Discard

    def show_error(self, title, message):
        QMessageBox.critical(self, title, message)

    def closeEvent(self, event: QCloseEvent):
        if self.maybe_save():
            event.accept()
        else:
            event.ignore()


if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = TextEditor()
    window.show()
    raise SystemExit(app.exec_())

为什么这些安全边界重要

读取成功后才改变编辑器状态

PyQt5 的静态文件对话框返回 (file_name, selected_filter) 元组。取消时路径为空,方法直接退出。程序先把文件读入临时 Python 字符串,只有解码成功后才修改 QTextEditcurrent_path 和修改标记,因此读取失败不会替换当前缓冲区。

utf-8-sig 同时接受普通 UTF-8,并移除开头的 UTF-8 BOM;它不会猜测旧编码。编码选择器应作为独立功能实现,并测试往返一致性。

保存会提交一个完整替代文件

直接用 Python open(path, "w") 打开目标,会在完整文档写完之前截断旧文件。QSaveFile 先在目标目录写临时文件,再在 commit() 时替代目标。本例检查写入字节数和提交结果,并保留 Qt 默认的“不直接写入备用”策略:无法创建原子临时文件时明确失败,而不是悄悄降级为风险更高的直接覆盖。

破坏性动作取决于保存结果

QTextDocument.modificationChanged 负责更新标题标记。执行“新建”“打开”或“关闭”前,maybe_save() 提供三个不同结果。只有保存返回 True 才继续;取消“另存为”会返回 False,因此请求保存的破坏性动作也随之取消。只有明确选择“丢弃”才放弃编辑。

安装与运行

请使用与操作系统匹配的软件包路径。Raspberry Pi OS PyQt5 安装指南已经维护了 APT、虚拟环境、GUI 与离屏测试。其他平台应遵循 Riverbank 安装文档,不要使用 sudo pip

把示例保存为 text_editor.py,再用已验证能导入 PyQt5 的解释器运行:

python3 text_editor.py

添加新功能前,请覆盖每条分支:

  • 取消“打开”,确认缓冲区不变。
  • 修改无标题缓冲区,选择“新建”→“保存”,再取消“另存为”;缓冲区必须保留。
  • 修改后关闭,在警告框选择“取消”;窗口必须继续存在。
  • 打开非 UTF-8 字节;旧缓冲区必须保留,并显示错误。
  • 保存到不可写位置;修改标记必须保留。
  • 成功保存、重新打开,并比较 UTF-8 字节。

必须明确的限制

  • QTextEdit.toPlainText() 是文本模型,不是逐字节保存编辑器。本例写 UTF-8,不保留原 BOM、旧编码或精确换行约定。
  • 大文件一次性载入内存,可能让 GUI 无响应。
  • 符号链接、权限、所有者、外部修改、并发写入、备份与版本历史都需要明确产品策略。
  • 原子替代受目标文件系统与目录权限影响。本例拒绝 Qt 的直接写入备用,让无法建立原子保存的错误可见。
  • 文件名过滤器只便于导航,不会让不可信内容变安全。本程序只显示纯文本,不执行打开的文件。

2019 年原始导出

归档中的 2019 Markdown 在 front matter 后立即结束,没有原始正文、代码、引文或来源链接可以保留。上面的教程是后来加入、并明确维护的内容。

官方资料

Leave a Reply