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.
Table of Contents
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.
