安全な PyQt5 テキストエディタ:開く、アトミック保存、未保存確認

2019年の原エクスポートにはfront matterしかなく、記事本文は空です。後のメンテナンスで最小エディタが追加されましたが、この2026年版では学びやすさを保ちながら、保存成功前に旧ファイルを切り詰めたり、編集内容を無警告で破棄したりしない例に仕上げます。

この例が保証すること

このプログラムは次を行います。

  • ファイルをUTF-8テキストとして扱い、推測せずにデコード失敗を報告する。
  • Openのキャンセルまたは読み込み失敗時に現在の文書を変更しない。
  • QTextDocumentの変更状態を追跡する。
  • New、Open、Closeで編集が失われる前にSave、Discard、Cancelを提示する。
  • QSaveFileを使い、書き込み全体が成功したあとでだけ保存先を置き換える。
  • 保存処理が真偽値を返し、Save Asのキャンセル時には、それを要求した破壊的操作もキャンセルする。

これは学習用エディタであり、成熟したコードエディタの代替ではありません。制約は後半に明記します。

完全なサンプル

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)タプルを返します。Cancelではパスが空なので、そのまま終了します。次に一時的なPython文字列へ読み、デコード成功後だけQTextEditcurrent_path、変更フラグを更新します。読み込み失敗で現在のバッファが置き換わることはありません。

utf-8-sigは通常のUTF-8を受け入れ、先頭のUTF-8 BOMも消費します。古い文字コードを推測はしません。文字コード選択は別機能として設計し、往復テストが必要です。

保存は完全な置換をコミットする

Pythonのopen(path, "w")で保存先を直接開くと、文書全体を書き終える前に旧ファイルを切り詰めます。QSaveFileは保存先ディレクトリの一時ファイルへ書き、commit()で保存先を置き換えます。コードは書き込んだバイト数とコミット結果の両方を確認します。またQt既定の「直接書き込みへフォールバックしない」動作を維持し、アトミックな一時ファイルを作れない場合は、危険な直接上書きへ黙って移行せず失敗を表示します。

破壊的操作は保存結果に従う

QTextDocument.modificationChangedがタイトルの印を更新します。New、Open、Closeの前にmaybe_save()が三つの結果を提示します。Saveは書き込みがTrueを返した場合だけ続行します。Save Asを取り消すとFalseになり、元の破壊的操作も中止されます。編集を捨てるのはDiscardを明示した場合だけです。

インストールと実行

OSに合うパッケージ経路を使ってください。メンテナンス済みのRaspberry Pi OS向けPyQt5導入ガイドでは、APT、仮想環境、GUI、オフスクリーンテストを説明しています。他のプラットフォームではRiverbankの導入文書に従い、sudo pipを使いません。

例をtext_editor.pyとして保存し、PyQt5のimportを確認済みのインタープリターで実行します。

python3 text_editor.py

機能を追加する前に、各分岐を試します。

  • Openをキャンセルし、バッファが変わらないこと。
  • 無題バッファを編集し、New→SaveのあとSave Asをキャンセルして、バッファが残ること。
  • 編集後に閉じ、警告でCancelを選ぶとウィンドウが残ること。
  • UTF-8でないバイトを開き、旧バッファが残ってエラーが表示されること。
  • 書き込めない場所へ保存し、変更印が残ること。
  • 正常保存後に再度開き、UTF-8バイトを比較できること。

明示すべき制約

  • QTextEdit.toPlainText()はテキストモデルであり、バイト保存エディタではありません。UTF-8で書き、元のBOM、旧文字コード、厳密な改行規則を保持しません。
  • 巨大ファイルは一度にメモリへ読み、GUIが応答しなくなる場合があります。
  • シンボリックリンク、権限、所有者、外部変更、同時書き込み、バックアップ、版履歴には明示的な製品方針が必要です。
  • アトミック置換は保存先のファイルシステムとディレクトリ権限に依存します。この例はQtの直接書き込みフォールバックを拒否し、アトミック保存を準備できないことを見える失敗にします。
  • ファイル名フィルターは操作を助けるだけで、信頼できない内容を安全にはしません。本プログラムはプレーンテキストを表示し、開いたファイルを実行しません。

2019年の原エクスポート

保存された2019年Markdownはfront matter直後で終わります。保存すべき原本文、コード、引用、出典リンクは存在しません。上のチュートリアルは後から追加し、明示的に保守している内容です。

公式資料

Leave a Reply