Was hat sich denn zwischen zwei Python-Dateien mit gleichem Namen geändert? Dass sie unterschiedlich sind,
ist klar, denn das Datum sagt es aus, und sie liegen in unterschiedlichen Ordnern. Aber was genau wurde
geändert? Das nachfolgende Programm, das mit Python (PyQt6) erstellt wurde, soll Klarheit schaffen.
Es analysiert zwei beliebige Dateien, egal ob Text-, Bild- oder binäre Dateien. Es ist eine weitere
Anwendung zur Lernreihe „Fenstertechnik“ mit Qt6-Python ohne Qt-Designer.
Was die App macht:
Zwei gestylte Buttons (grün/orange) öffnen je einen Explorer-Dialog für Datei A und Datei B.
Ein Dropdown wählt die Vergleichsart: Automatisch (erkennt anhand der Dateiendung), Text, Bild oder Binär.
Text: zeilenbasierter Diff (wie ein Unified-Diff) mit farblich markierten Zeilen (hinzugefügt/entfernt).
Bild: Pixelvergleich per Pillow – zeigt nur „identisch“ oder „unterschiedlich" (inkl. Hinweis bei abweichender Auflösung).
Binär: SHA-256-Schnellvergleich, bei Unterschied Auflistung der abweichenden Byte-Offsets (Hex).
Im Infofeld werden vor dem eigentlichen Ergebnis immer Name, Erstellungs-/Änderungsdatum und Größe beider Dateien
angezeigt. Fehlerfälle (fehlende Datei, fehlendes Pillow, Leserechte etc.) werden ebenfalls im Infofeld farblich
hervorgehoben. Komplett im Dark Mode via QSS-Stylesheet.
Zur Erinnerung! Der Schwerpunkt dieser Applikation basiert auf „Was hat sich bei einer Programmiersprachen-Quellcodedatei
geändert?“. „Die neue Version läuft plötzlich nicht mehr.“ Bevor man den Debugger anschmeißt, analysieren, wo man Änderungen
durchgeführt hat. Das obige Bild zeigt Details zur Visualisierung der Unterschiede: Für beide Dateien werden Erstellungsdatum,
Datum der letzten Änderung und die Größe der Datei angezeigt. Danach erfolgt der Hinweis: Dateien sind gleich oder
unterschiedlich. Sind sie unterschiedlich, erfolgt eine Visualisierung der Zeilen, in denen sich die Dateien unterscheiden,
in roter bzw. grüner Farbe. Die Sonderzeichen wie „+“, "-", "@@" und „Datei A/B“ werden weiter unten in dieser Beschreibung
genau erklärt. Im obigen Bild sind es genau drei Zeilen, die unterschiedlich sind.
#!/usr/bin/env python3
# *****************************************************************************
# Eine PyQt6-Anwendung im Dark Mode zum Vergleichen von zwei Dateien.
# Unterstützte Vergleichsarten:
# - Text -> zeilenbasierter Diff mit Zeilennummern
# - Bild -> Vergleich per Pixel (nur "gleich"/"unterschiedlich")
# - Binär -> byteweiser Vergleich mit Angabe der abweichenden Offsets
# Installation der Abhängigkeiten: pip install PyQt6 Pillow
# (c) Hans Busche Datum: 30.08.2026
# *****************************************************************************
import sys
import os
import difflib
import hashlib
from pathlib import Path
from datetime import datetime
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QFileDialog, QComboBox, QTextEdit, QGroupBox,
QSizePolicy, QFrame
)
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QFont, QColor, QTextCursor, QTextCharFormat
try:
from PIL import Image, ImageChops
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
# Verzeichnis dieser Datei, damit das Icon unabhängig vom Startort gefunden wird
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ICON_PATH = os.path.join(BASE_DIR, "Schaefer.ico")
# *****************************************************************************
# Dark-Mode-Stylesheet
# *****************************************************************************
DARK_STYLESHEET = """
QMainWindow, QWidget {
background-color: #1e1e1e;
color: #d4d4d4;
font-family: "Segoe UI", "Ubuntu", sans-serif;
font-size: 13px;
}
QLabel#titleLabel {
font-size: 20px;
font-weight: 600;
color: #ffffff;
padding-bottom: 4px;
}
QGroupBox {
border: 1px solid #3c3c3c;
border-radius: 8px;
margin-top: 10px;
padding: 12px;
font-weight: 600;
color: #cccccc;
background-color: #252526;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 10px;
padding: 0 6px;
color: #9cdcfe;
}
QLabel#pathLabel {
color: #9a9a9a;
padding-top: 6px;
font-style: italic;
}
QLabel#detailLabel {
color: #d4d4d4;
}
QPushButton {
border: none;
border-radius: 6px;
padding: 9px 16px;
color: white;
font-weight: 600;
}
QPushButton#fileButtonA {
background-color: #2d7d46;
}
QPushButton#fileButtonA:hover {
background-color: #35934f;
}
QPushButton#fileButtonA:pressed {
background-color: #256339;
}
QPushButton#fileButtonB {
background-color: #c9702e;
}
QPushButton#fileButtonB:hover {
background-color: #de8039;
}
QPushButton#fileButtonB:pressed {
background-color: #a85d24;
}
QPushButton#compareButton {
background-color: #0e639c;
padding: 10px 24px;
font-size: 14px;
}
QPushButton#compareButton:hover {
background-color: #1177bb;
}
QPushButton#compareButton:pressed {
background-color: #0a4d7a;
}
QPushButton#exportButton {
background-color: #5a3d8a;
padding: 10px 20px;
font-size: 14px;
}
QPushButton#exportButton:hover {
background-color: #6c49a3;
}
QPushButton#exportButton:pressed {
background-color: #472f6e;
}
QPushButton#exportButton:disabled {
background-color: #3c3c3c;
color: #7a7a7a;
}
QStatusBar {
background-color: #1e1e1e;
color: #9cdcfe;
}
QComboBox {
background-color: #2d2d2d;
border: 1px solid #3c3c3c;
border-radius: 6px;
padding: 6px 10px;
min-width: 160px;
color: #d4d4d4;
}
QComboBox:hover {
border: 1px solid #0e639c;
}
QComboBox::drop-down {
border: none;
width: 24px;
}
QComboBox QAbstractItemView {
background-color: #2d2d2d;
selection-background-color: #0e639c;
color: #d4d4d4;
border: 1px solid #3c3c3c;
}
QTextEdit#infoField {
background-color: #121212;
border: 1px solid #3c3c3c;
border-radius: 8px;
padding: 10px;
color: #d4d4d4;
}
QLabel#sectionLabel {
font-weight: 600;
color: #9cdcfe;
padding-top: 6px;
}
QScrollBar:vertical {
background: #1e1e1e;
width: 12px;
}
QScrollBar::handle:vertical {
background: #3c3c3c;
border-radius: 6px;
min-height: 24px;
}
QScrollBar::handle:vertical:hover {
background: #4a4a4a;
}
"""
# *****************************************************************************
# Hilfsfunktionen
# *****************************************************************************
def format_size(size_bytes: int) -> str:
"""Formatiert eine Byte-Angabe menschenlesbar."""
size = float(size_bytes)
for unit in ("Bytes", "KB", "MB", "GB", "TB"):
if size < 1024.0:
return f"{size:.2f} {unit}" if unit != "Bytes" else f"{int(size)} {unit}"
size /= 1024.0
return f"{size:.2f} PB"
def get_file_info(path: str) -> dict:
"""Liest Name, Erstellungsdatum und Größe einer Datei aus."""
stat = os.stat(path)
# Hinweis: os.path.getctime liefert unter Windows das Erstellungsdatum,
# unter Linux/Mac eher das Datum der letzten Metadaten-Änderung.
created = datetime.fromtimestamp(stat.st_ctime).strftime("%d.%m.%Y %H:%M:%S")
modified = datetime.fromtimestamp(stat.st_mtime).strftime("%d.%m.%Y %H:%M:%S")
return {
"name": os.path.basename(path),
"path": path,
"created": created,
"modified": modified,
"size": format_size(stat.st_size),
"size_bytes": stat.st_size,
}
def read_text_with_fallback(path: str) -> str:
"""Liest eine Textdatei, versucht UTF-8 und fällt sonst auf Latin-1 zurück."""
try:
with open(path, "r", encoding="utf-8") as f:
return f.read()
except UnicodeDecodeError:
with open(path, "r", encoding="latin-1") as f:
return f.read()
# *****************************************************************************
# Formatierter Text ohne HTML-Syntax
# *****************************************************************************
class RichTextWriter:
"""
Schreibt farbig/fett formatierten Text in ein QTextEdit.
Nutzt QTextCursor/QTextCharFormat statt HTML-Strings -
im Code taucht dadurch keinerlei HTML-Syntax auf.
"""
def __init__(self, text_edit: QTextEdit):
self.edit = text_edit
def clear(self):
self.edit.clear()
def _end_cursor(self) -> QTextCursor:
cursor = self.edit.textCursor()
cursor.movePosition(QTextCursor.MoveOperation.End)
return cursor
def write(self, text: str, color: str = None, bold: bool = False,
italic: bool = False, background: str = None):
"""Fügt Text mit optionaler Formatierung an, ohne Zeilenumbruch."""
char_format = QTextCharFormat()
if color:
char_format.setForeground(QColor(color))
if background:
char_format.setBackground(QColor(background))
if bold:
char_format.setFontWeight(QFont.Weight.Bold)
if italic:
char_format.setFontItalic(True)
cursor = self._end_cursor()
cursor.insertText(text, char_format)
self.edit.setTextCursor(cursor)
def newline(self):
cursor = self._end_cursor()
cursor.insertBlock()
self.edit.setTextCursor(cursor)
def write_line(self, text: str = "", **kwargs):
"""Fügt Text an und schließt mit einem Zeilenumbruch ab."""
self.write(text, **kwargs)
self.newline()
# *****************************************************************************
# Hauptfenster
# *****************************************************************************
class FileComparerApp(QMainWindow):
TEXT_EXTENSIONS = {
".txt", ".md", ".csv", ".json", ".xml", ".py", ".html", ".htm",
".css", ".js", ".log", ".ini", ".yaml", ".yml", ".cfg", ".conf",
".java", ".c", ".cpp", ".h", ".sh", ".bat", ".ts", ".sql"
}
IMAGE_EXTENSIONS = {
".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff", ".tif", ".webp", ".ico"
}
MAX_BINARY_DIFFS = 100 # max. Anzahl angezeigter Byte-Unterschiede
MAX_TEXT_DIFF_LINES = 400 # max. Anzahl angezeigter Diff-Zeilen
def __init__(self):
super().__init__()
self.file_a = None
self.file_b = None
self.init_ui()
# *****************************************************************************
# UI-Aufbau
# *****************************************************************************
def init_ui(self):
self.setWindowTitle("Datei Vergleichen")
self.resize(920, 720)
central = QWidget()
self.setCentralWidget(central)
main_layout = QVBoxLayout(central)
main_layout.setSpacing(14)
main_layout.setContentsMargins(20, 20, 20, 20)
title = QLabel("📁 Datei Vergleichen")
title.setObjectName("titleLabel")
main_layout.addWidget(title)
# --- Datei-Auswahl ---
files_layout = QHBoxLayout()
files_layout.setSpacing(14)
self.group_a, self.btn_a, self.label_a = self._create_file_group(
"Datei A", "fileButtonA", self.load_file_a
)
self.group_b, self.btn_b, self.label_b = self._create_file_group(
"Datei B", "fileButtonB", self.load_file_b
)
files_layout.addWidget(self.group_a)
files_layout.addWidget(self.group_b)
main_layout.addLayout(files_layout)
# --- Optionen / Vergleichsstart ---
options_layout = QHBoxLayout()
mode_label = QLabel("Vergleichsart:")
self.mode_combo = QComboBox()
self.mode_combo.addItems(["Automatisch", "Text", "Bild", "Binär"])
self.compare_btn = QPushButton("🔍 Vergleichen")
self.compare_btn.setObjectName("compareButton")
self.compare_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self.compare_btn.clicked.connect(self.compare_files)
self.export_btn = QPushButton("💾 Exportieren")
self.export_btn.setObjectName("exportButton")
self.export_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self.export_btn.setEnabled(False)
self.export_btn.clicked.connect(self.export_results)
options_layout.addWidget(mode_label)
options_layout.addWidget(self.mode_combo)
options_layout.addStretch()
options_layout.addWidget(self.compare_btn)
options_layout.addWidget(self.export_btn)
main_layout.addLayout(options_layout)
self.statusBar().showMessage("Bereit.")
# --- Infofeld ---
result_label = QLabel("Ergebnis")
result_label.setObjectName("sectionLabel")
main_layout.addWidget(result_label)
self.info_field = QTextEdit()
self.info_field.setObjectName("infoField")
self.info_field.setReadOnly(True)
mono_font = QFont("Consolas")
mono_font.setStyleHint(QFont.StyleHint.Monospace)
mono_font.setPointSize(10)
self.info_field.setFont(mono_font)
main_layout.addWidget(self.info_field, stretch=1)
self.writer = RichTextWriter(self.info_field)
self._show_placeholder()
def _create_file_group(self, title: str, button_id: str, handler):
group = QGroupBox(title)
layout = QVBoxLayout(group)
btn = QPushButton(f"📂 {title} wählen")
btn.setObjectName(button_id)
btn.setCursor(Qt.CursorShape.PointingHandCursor)
btn.clicked.connect(handler)
path_label = QLabel("Keine Datei ausgewählt")
path_label.setObjectName("pathLabel")
path_label.setWordWrap(True)
layout.addWidget(btn)
layout.addWidget(path_label)
return group, btn, path_label
def _show_placeholder(self):
self.writer.clear()
self.writer.write_line(
"Bitte wähle Datei A und Datei B aus und klicke auf „Vergleichen“.",
color="#9a9a9a", italic=True
)
# *****************************************************************************
# Datei-Auswahl
# *****************************************************************************
def load_file_a(self):
path, _ = QFileDialog.getOpenFileName(self, "Datei A auswählen")
if path:
self.file_a = path
self.label_a.setText(path)
def load_file_b(self):
path, _ = QFileDialog.getOpenFileName(self, "Datei B auswählen")
if path:
self.file_b = path
self.label_b.setText(path)
# *****************************************************************************
# Vergleichslogik
# *****************************************************************************
def detect_mode(self, path_a: str, path_b: str) -> str:
ext_a = Path(path_a).suffix.lower()
ext_b = Path(path_b).suffix.lower()
if ext_a in self.IMAGE_EXTENSIONS or ext_b in self.IMAGE_EXTENSIONS:
return "Bild"
if ext_a in self.TEXT_EXTENSIONS or ext_b in self.TEXT_EXTENSIONS:
return "Text"
return "Binär"
def compare_files(self):
self.writer.clear()
if not self.file_a or not self.file_b:
self._render_error("Bitte wähle beide Dateien aus, bevor du den Vergleich startest.")
return
if not os.path.isfile(self.file_a) or not os.path.isfile(self.file_b):
self._render_error("Eine der ausgewählten Dateien existiert nicht mehr oder ist nicht lesbar.")
return
try:
info_a = get_file_info(self.file_a)
info_b = get_file_info(self.file_b)
except OSError as e:
self._render_error(f"Fehler beim Lesen der Datei-Informationen: {e}")
return
mode = self.mode_combo.currentText()
if mode == "Automatisch":
mode = self.detect_mode(self.file_a, self.file_b)
self._write_details(info_a, info_b, mode)
try:
if mode == "Text":
self._compare_text(self.file_a, self.file_b)
elif mode == "Bild":
self._compare_images(self.file_a, self.file_b)
else:
self._compare_binary(self.file_a, self.file_b)
except Exception as e:
self.writer.write_line("⚠ Fehler beim Vergleich", color="#f14c4c", bold=True)
self.writer.write_line(str(e), color="#f14c4c")
self.export_btn.setEnabled(True)
self.statusBar().showMessage("Vergleich mit Fehler beendet.", 5000)
return
self.export_btn.setEnabled(True)
self.statusBar().showMessage("Vergleich abgeschlossen.", 5000)
# *****************************************************************************
# Detail-Anzeige (Name, Datum, Größe)
# *****************************************************************************
def _write_details(self, info_a: dict, info_b: dict, mode: str):
self.writer.write_line(f"Vergleichsart: {mode}", color="#569cd6", bold=True)
self.writer.newline()
self._write_file_block(info_a, "#2d9d55", "Datei A")
self.writer.newline()
self._write_file_block(info_b, "#e08a3f", "Datei B")
self.writer.newline()
self.writer.write_line("-" * 64, color="#3c3c3c")
self.writer.newline()
def _write_file_block(self, info: dict, farbe: str, titel: str):
self.writer.write_line(titel, color=farbe, bold=True)
self.writer.write(" Name: ", color="#9a9a9a")
self.writer.write_line(info["name"])
self.writer.write(" Erstellt: ", color="#9a9a9a")
self.writer.write_line(info["created"])
self.writer.write(" Geändert: ", color="#9a9a9a")
self.writer.write_line(info["modified"])
self.writer.write(" Größe: ", color="#9a9a9a")
self.writer.write_line(info["size"])
# *****************************************************************************
# Export der Ergebnisse
# *****************************************************************************
def export_results(self):
if not self.info_field.toPlainText().strip():
return
default_name = "vergleichsergebnis.txt"
path, selected_filter = QFileDialog.getSaveFileName(
self,
"Ergebnisse exportieren",
default_name,
"Textdatei (*.txt);;HTML-Datei (*.html)"
)
if not path:
return
wants_html = path.lower().endswith(".html") or "HTML" in selected_filter
if wants_html and not path.lower().endswith(".html"):
path += ".html"
elif not wants_html and not path.lower().endswith(".txt"):
path += ".txt"
try:
if wants_html:
content = self.info_field.toHtml()
else:
content = self.info_field.toPlainText()
with open(path, "w", encoding="utf-8") as f:
f.write(content)
except OSError as e:
self.statusBar().showMessage(f"⚠ Export fehlgeschlagen: {e}", 8000)
return
self.statusBar().showMessage(f"Ergebnisse exportiert nach: {path}", 6000)
def _render_error(self, message: str):
self.writer.clear()
self.writer.write_line("⚠ Fehler", color="#f14c4c", bold=True)
self.writer.write_line(message, color="#f14c4c")
# *****************************************************************************
# Text-Vergleich
# *****************************************************************************
def _compare_text(self, path_a: str, path_b: str):
text_a = read_text_with_fallback(path_a)
text_b = read_text_with_fallback(path_b)
if text_a == text_b:
self.writer.write_line("✔ Die Textdateien sind identisch.", color="#4ec9b0", bold=True)
return
lines_a = text_a.splitlines()
lines_b = text_b.splitlines()
diff = list(difflib.unified_diff(
lines_a, lines_b,
fromfile="Datei A", tofile="Datei B",
lineterm=""
))
truncated = len(diff) > self.MAX_TEXT_DIFF_LINES
diff = diff[:self.MAX_TEXT_DIFF_LINES]
self.writer.write_line("✘ Die Textdateien unterscheiden sich.", color="#f14c4c", bold=True)
self.writer.newline()
for line in diff:
if line.startswith("+++") or line.startswith("---"):
self.writer.write_line(line, color="#569cd6", bold=True)
elif line.startswith("@@"):
self.writer.write_line(line, color="#c586c0")
elif line.startswith("+"):
self.writer.write_line(line, color="#4ec9b0", background="#1b3a2b")
elif line.startswith("-"):
self.writer.write_line(line, color="#f14c4c", background="#3a1b1b")
else:
self.writer.write_line(line, color="#9a9a9a")
if truncated:
self.writer.newline()
self.writer.write_line(
f"… Ausgabe nach {self.MAX_TEXT_DIFF_LINES} Zeilen gekürzt.",
color="#9a9a9a", italic=True
)
# *****************************************************************************
# Bild-Vergleich
# *****************************************************************************
def _compare_images(self, path_a: str, path_b: str):
if not PIL_AVAILABLE:
raise RuntimeError(
"Das Paket „Pillow“ ist nicht installiert. Bitte mit "
"„pip install Pillow“ nachinstallieren, um Bilder vergleichen zu können."
)
img_a = Image.open(path_a)
img_b = Image.open(path_b)
if img_a.size != img_b.size:
self.writer.write_line("✘ Die Bilder sind unterschiedlich.", color="#f14c4c", bold=True)
self.writer.write_line(
f"Unterschiedliche Bildgröße: {img_a.size[0]}×{img_a.size[1]} px "
f"vs. {img_b.size[0]}×{img_b.size[1]} px.",
color="#9a9a9a"
)
return
img_a_conv = img_a.convert("RGBA")
img_b_conv = img_b.convert("RGBA")
diff = ImageChops.difference(img_a_conv, img_b_conv)
if diff.getbbox() is None:
self.writer.write_line("✔ Die Bilder sind identisch.", color="#4ec9b0", bold=True)
else:
self.writer.write_line("✘ Die Bilder sind unterschiedlich.", color="#f14c4c", bold=True)
# *****************************************************************************
# Binär-Vergleich
# *****************************************************************************
def _compare_binary(self, path_a: str, path_b: str):
with open(path_a, "rb") as f:
data_a = f.read()
with open(path_b, "rb") as f:
data_b = f.read()
hash_a = hashlib.sha256(data_a).hexdigest()
hash_b = hashlib.sha256(data_b).hexdigest()
if hash_a == hash_b:
self.writer.write_line(
"✔ Die Dateien sind binär identisch (SHA-256 stimmt überein).",
color="#4ec9b0", bold=True
)
return
self.writer.write_line("✘ Die Dateien unterscheiden sich.", color="#f14c4c", bold=True)
len_a, len_b = len(data_a), len(data_b)
if len_a != len_b:
self.writer.write_line(
f"Unterschiedliche Dateigröße: {format_size(len_a)} vs. {format_size(len_b)}.",
color="#9a9a9a"
)
self.writer.newline()
min_len = min(len_a, len_b)
diffs = []
for i in range(min_len):
if data_a[i] != data_b[i]:
diffs.append(i)
if len(diffs) >= self.MAX_BINARY_DIFFS:
break
if not diffs:
self.writer.write_line(
"Keine byteweisen Unterschiede im gemeinsamen Bereich gefunden.",
color="#9a9a9a"
)
return
for offset in diffs:
self.writer.write(f"Offset 0x{offset:08X} ", color="#9a9a9a")
self.writer.write("A=", color="#9a9a9a")
self.writer.write(f"0x{data_a[offset]:02X}", color="#4ec9b0")
self.writer.write(" B=", color="#9a9a9a")
self.writer.write_line(f"0x{data_b[offset]:02X}", color="#e08a3f")
if len(diffs) >= self.MAX_BINARY_DIFFS:
self.writer.newline()
self.writer.write_line(
f"… Ausgabe nach {self.MAX_BINARY_DIFFS} Unterschieden gekürzt.",
color="#9a9a9a", italic=True
)
# *****************************************************************************
# Einstiegspunkt
# *****************************************************************************
def main():
app = QApplication(sys.argv)
app.setStyleSheet(DARK_STYLESHEET)
window = FileComparerApp()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
# *****************************************************************************
# Ende
# *****************************************************************************
Grober Aufbau. Die Datei gliedert sich in 5 Blöcke:
Imports & optionales Pillow.
DARK_STYLESHEET – das Aussehen.
Freie Hilfsfunktionen (format_size, get_file_info, read_text_with_fallback).
RichTextWriter – schreibt formatierten Text ins Infofeld.
FileComparerApp – das eigentliche Fenster mit der ganzen Logik.
main() – Startpunkt.
Imports (Zeile 11–30):
Neben Standardbibliotheken (os, difflib für Textvergleiche, hashlib für Prüfsummen, pathlib, datetime)
kommen die PyQt6-Bausteine für UI-Elemente. Pillow (PIL) wird optional importiert – falls es fehlt, bleibt
PIL_AVAILABLE = False, und der Bildvergleich meldet später einen sauberen Fehler statt eines Absturzes.
DARK_STYLESHEET:
Ein großer String im QSS-Format (Qt Style Sheets – CSS-ähnlich, aber für Qt-Widgets). Jeder Button/jedes Label
hat über setObjectName(...) einen eindeutigen Namen (z. B. fileButtonA), auf den sich hier ein Selektor wie
QPushButton#fileButtonA bezieht. So bekommt jeder Button seine eigene Farbe, ohne dass man Farben im
Python-Code hart verdrahten muss.
Hilfsfunktionen:
format_size: rechnet Bytes in KB/MB/GB um.
get_file_info: liest über os.stat() Name, Erstellungs-/Änderungsdatum und Größe einer Datei aus.
read_text_with_fallback: versucht eine Textdatei als UTF-8 zu lesen, fällt bei Fehlern auf Latin-1 zurück
(deckt die meisten europäischen Textdateien ab).
RichTextWriter:
Er kapselt drei Operationen auf dem QTextEdit: 1. write(text, color=..., bold=..., ...) – fügt Text mit einem
QTextCharFormat an der aktuellen Cursor-Position ein. 2. newline() – erzeugt einen neuen Absatz.
3. write_line(...) – Kombination aus beidem.
FileComparerApp. Das Hauptfenster:
init_ui() baut die Oberfläche von oben nach unten auf:
Titel-Label
Zwei QGroupBoxen nebeneinander (Datei A / B) mit je einem Button und einem Pfad-Label — erzeugt über die kleine
Fabrikmethode _create_file_group(), damit der Code nicht doppelt existiert.
Eine Optionszeile: Dropdown für die Vergleichsart, „Vergleichen“- und „Exportieren“-Button.
Eine Statusleiste (self.statusBar()) für kurze Meldungen.
Das QTextEdit-Infofeld mit fester Monospace-Schrift, darüber wird self.writer als RichTextWriter angelegt.
Datei-Auswahl: (load_file_a/load_file_b): öffnet QFileDialog.getOpenFileName, speichert den Pfad
in self.file_a/self.file_b und zeigt ihn im Label an.
detect_mode(): schaut sich die Dateiendungen an und rät bei „Automatisch“ zwischen Text/Bild/Binär.
compare_files() ist der zentrale Ablauf, der beim Klick auf „Vergleichen“ läuft:
Infofeld leeren.
Prüfen: Sind beide Dateien gewählt und existieren sie noch?
get_file_info() für beide Dateien holen.
Modus bestimmen (bzw. automatisch erkennen).
_write_details() schreibt den Block mit Name/Datum/Größe für A und B.
Je nach Modus wird _compare_text, _compare_images oder _compare_binary aufgerufen – die schreiben ihr Ergebnis direkt
über self.writer ins Feld.
Bei einer Exception (z. B. Pillow fehlt) wird die Fehlermeldung rot ausgegeben, statt dass das Programm abstürzt.
Am Ende wird der „Exportieren“-Button aktiviert.
Die drei Vergleichsmethoden im Detail:
_compare_text: nutzt difflib.unified_diff, um zeilenweise Unterschiede zu berechnen, und färbt Zeilen ein
(Rot = entfernt, Grün = hinzugefügt, Blau = Dateikopf).
_compare_images: öffnet beide Bilder mit Pillow, vergleicht bei gleicher Größe die Pixel per ImageChops.
difference – ist die „Differenzbox“ leer, sind die Bilder identisch.
_compare_binary: vergleicht zuerst per SHA-256-Hash (schneller Identisch-Check), listet bei Unterschieden sonst die
ersten 100 abweichenden Byte-Offsets in Hex auf.
export_results(): öffnet einen Speichern-Dialog, schreibt je nach gewählter Endung entweder toPlainText()
(reiner Text) oder toHtml() (von Qt automatisch generiertes HTML mit allen Farben) in eine Datei.
main():
Erstellt die QApplication, wendet das Stylesheet global an, zeigt das Fenster und startet die Event-Loop
mit app.exec().
Der Diff-Teil steckt komplett in difflib.unified_diff() aus der Python-Standardbibliothek – hier
lohnt sich ein genauerer Blick, wie das eigentlich funktioniert:
diff = list(difflib.unified_diff(
lines_a, lines_b,
fromfile="Datei A", tofile="Datei B",
lineterm=""))
lines_a/lines_b sind Listen von Strings (text.splitlines()) – der Vergleich läuft also zeilenweise, nicht zeichenweise.
Zwei Dateien, die sich nur durch ein einziges Leerzeichen mitten in einer langen Zeile unterscheiden, zeigen als Diff
trotzdem die komplette Zeile als geändert – das ist eine bewusste Grenze von unified_diff.
Der Algorithmus dahinter: SequenceMatcher unified_diff ruft intern difflib.SequenceMatcher
auf. Das ist eine Variante des Ratcliff-Obershelp-Algorithmus, kein klassischer LCS-Algorithmus (wie diff unter Linux
ihn nutzt), auch wenn das Ergebnis ähnlich aussieht. Grob passiert das:
Längsten gemeinsamen zusammenhängenden Block finden zwischen lines_a und lines_b (nicht die längste gemeinsame Teilfolge
über die ganze Datei, sondern den längsten zusammenhängenden Treffer).
Dieser Block teilt beide Listen in einen Bereich davor und einen Bereich danach.
Schritt 1 wird rekursiv auf beide Teilbereiche angewendet, bis nichts mehr übrig ist.
Am Ende ergibt sich eine Folge von Opcodes: equal, replace, insert, delete – jeweils mit Start-/Endindex in beiden Listen.
Für die Blockerkennung nutzt SequenceMatcher einen Index (Dictionary: Zeileninhalt → Positionen), damit er nicht bei
jedem Vergleich quadratisch alles durchsuchen muss. Es gibt zusätzlich eine „Autojunk"-Heuristik: Zeilen, die in
einer der beiden Dateien sehr häufig vorkommen (Standard: mehr als 1 % aller Zeilen bei Dateien >200 Zeilen),
werden für die Blocksuche ignoriert, damit z. B. viele leere Zeilen die Treffererkennung nicht verfälschen.
Das Ausgabeformat (Unified Diff) unified_diff verpackt die Opcodes in das bekannte Format,
das diff -u auch erzeugt:
--- Datei A
+++ Datei B
@@ -12,3 +12,4 @@
unveränderte Zeile
-alte Zeile
+neue Zeile
+noch eine neue Zeile
unveränderte Zeile
--- Datei A / +++ Datei B – die beiden Dateinamen (kommen von fromfile/tofile).
@@ -12,3 +12,4 @@ – der „Hunk-Header": ab Zeile 12 werden 3 Zeilen aus A bzw. 4 Zeilen aus B gezeigt.
Zeilen ohne Präfix = unverändert (Kontext).
- = nur in A vorhanden (entfernt).
+ = nur in B vorhanden (hinzugefügt).
Warum lineterm=""? splitlines() entfernt die Zeilenumbrüche bereits.
unified_diff würde von sich aus \n an ---/+++/@@-Zeilen anhängen (praktisch für Datei-Ausgabe).
Da wir die Zeilen selbst über write_line() mit einem eigenen newline() abschließen, würde ein
zusätzliches \n nur eine Leerzeile erzeugen – lineterm="" unterdrückt das.
Ab 400 Zeilen ist Schluss!!
Bei riesigen Dateien mit tausenden Unterschieden würde das Infofeld sonst unbrauchbar lang werden. Der Diff
wird nach 400 Zeilen (Klassenkonstante MAX_TEXT_DIFF_LINES) abgeschnitten, mit einem Hinweis am Ende.
PyQt6. Zwei Dateien vergleichen. Ohne Gewähr © 2026 Hans Busche