Build a Safer PyQt5 Text Editor: Open, Atomic Save, and Unsaved-Change Prompts

The original 2019 export for this post contains front matter but no article body. A later maintenance pass supplied a minimal editor; this 2026 edition replaces that example with a small program that is still teachable but does not silently discard edits or truncate the old file before a save succeeds.

What this example guarantees

The program:

  • treats files as UTF-8 text and reports decoding failures instead of guessing;
  • leaves the current document untouched when Open is cancelled or reading fails;
  • tracks QTextDocument‘s modified state;
  • asks Save, Discard, or Cancel before New, Open, or Close would lose edits;
  • uses QSaveFile, so a complete write is committed in place of the destination only after the write succeeds; and
  • returns a Boolean result from saving, so cancelling Save As also cancels the destructive action that requested it.

It is a learning editor, not a replacement for a mature code editor. Its limitations are explicit below.

Complete example

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_())

Why the safety boundaries matter

Open changes state only after a successful read

PyQt5’s static file-dialog functions return a (file_name, selected_filter) tuple. Cancel produces an empty path, so the method exits. The program then reads into a temporary Python string and changes QTextEdit, current_path, and the modified flag only after decoding succeeds. A failed read cannot replace the current buffer.

utf-8-sig accepts ordinary UTF-8 and consumes a leading UTF-8 byte-order mark. It does not guess legacy encodings. Adding an encoding chooser is a separate feature and needs tests for round trips.

Save commits a complete replacement

Opening a destination directly with Python’s open(path, "w") truncates it before the whole document has been written. QSaveFile instead writes a temporary file in the destination directory and replaces the destination on commit(). The code checks both the byte count and the commit result. It intentionally keeps Qt’s default no-fallback behavior: when an atomic temporary file cannot be created, Save fails rather than quietly using a riskier direct write.

A destructive action depends on the save result

QTextDocument.modificationChanged updates the title marker. Before New, Open, or Close, maybe_save() offers three distinct outcomes. Save continues only if the write returns True; cancelling Save As returns False, so the requested destructive action is also cancelled. Discard is the only path that intentionally abandons edits.

Install and run

Use the package route appropriate to the operating system. The maintained Raspberry Pi OS PyQt5 installation guide explains APT, virtual environments, GUI checks, and offscreen tests. On another platform, follow Riverbank’s installation documentation rather than using sudo pip.

Save the example as text_editor.py, then run it with the interpreter in which PyQt5 was verified:

python3 text_editor.py

Exercise each branch before adding features:

  • Cancel Open and confirm the buffer is unchanged.
  • Edit an untitled buffer, choose New, select Save, then cancel Save As; the buffer must remain.
  • Make a change and choose Cancel in the close warning; the window must stay open.
  • Try to open non-UTF-8 bytes; the old buffer must remain and an error must appear.
  • Save to an unwritable location; the modified marker must remain.
  • Save successfully, reopen the file, and compare the UTF-8 bytes.

Limits to state honestly

  • QTextEdit.toPlainText() is a text model, not a byte-preserving editor. The example writes UTF-8 and does not preserve an original BOM, legacy encoding, or exact line-ending convention.
  • Very large files are loaded into memory at once and can make the GUI unresponsive.
  • Symlinks, file permissions, ownership, external modifications, concurrent writers, and backup/version history need an explicit product policy.
  • Atomic replacement depends on the destination filesystem and directory permissions. This example refuses Qt’s direct-write fallback so a failed atomic setup is visible.
  • File-name filters improve navigation; they do not make untrusted content safe. This program displays plain text and never executes the opened file.

Original 2019 export

The archived 2019 Markdown ends immediately after its front matter. There is no original body, code sample, quotation, or source link to preserve. The tutorial above is a later, explicitly maintained addition.

Official references

Leave a Reply