diff --git a/docs/blake2b_hash_implementation.md b/docs/blake2b_hash_implementation.md new file mode 100644 index 0000000..6bf1ba2 --- /dev/null +++ b/docs/blake2b_hash_implementation.md @@ -0,0 +1,196 @@ +# Blake2b-Hash-Implementierung für XML-Dateien + +## Übersicht + +Diese Dokumentation beschreibt die Implementierung der automatischen blake2b-Hash-Berechnung für XML-Dateien in der XSL-Validator-Anwendung. + +## Funktionalität + +### Automatische Hash-Berechnung beim Projektladen + +Beim Laden eines Projekts in der `MainWindow`-Klasse werden automatisch alle XML-Dateien auf fehlende `hashsum`-Eigenschaften geprüft. Für Dateien ohne Hash-Wert wird dieser asynchron im Hintergrund berechnet. + +### Hash-Algorithmus + +- **Algorithmus**: blake2b (aus Python's `hashlib`) +- **Präfix**: `blake2b:` +- **Format**: `blake2b:<64-stelliger-hex-hash>` + +### Beispiel + +``` +blake2b:c0d6043a79e953f31921a68f008d20ef3bce5bc7f9613d0f53c3a3d3f0a5879e4a18b955f1741ddf190cf7769c1be47d4cf48246f455fa9f84e6599ce5b45119 +``` + +## Technische Implementierung + +### Erweiterte XmlFile-Klasse + +Die `conf.XmlFile`-Klasse wurde um die optionale `hashsum`-Eigenschaft erweitert: + +```python +class XmlFile(BaseModel): + xml: Path + hashsum: str | None = None # Neue Eigenschaft +``` + +### Asynchrone Hash-Berechnung + +#### XmlHashCalculatorThread + +Eine spezialisierte `QThread`-Klasse für die asynchrone Hash-Berechnung: + +```python +class XmlHashCalculatorThread(QThread): + hash_calculated = pyqtSignal(object, str) + calculation_finished = pyqtSignal(int, int) + error_occurred = pyqtSignal(str, str) +``` + +**Hauptmethoden:** +- `_calculate_blake2b_hash()`: Berechnet den blake2b-Hash einer Datei +- `run()`: Hauptschleife für die asynchrone Verarbeitung + +#### Integration in MainWindow + +**Automatischer Start:** +```python +def load_project_from_file(self, file_path: Path): + # ... Projekt laden ... + # Starte Hash-Berechnung für alle XML-Dateien + self._start_xml_hash_calculation() +``` + +**Kern-Methoden:** +- `_start_xml_hash_calculation()`: Startet die asynchrone Hash-Berechnung +- `_collect_all_xml_files()`: Sammelt alle XML-Dateien rekursiv +- `_on_hash_calculated()`: Signal-Handler für berechnete Hashes +- `_calculate_hash_for_xml_file()`: Synchrone Hash-Berechnung für neue Dateien + +### Rekursive Datensammlung + +Die Implementierung durchsucht rekursiv alle `TreeNode`- und `XslFile`-Strukturen: + +```python +def _collect_all_xml_files(self, node: TreeNode) -> list[XmlFile]: + xml_files = [] + + # Sammle XML-Dateien aus XSL-Dateien + for xsl_file in node.xsl_files: + xml_files.extend(xsl_file.xml_files) + + # Rekursiv durch Kindknoten + for child in node.children: + xml_files.extend(self._collect_all_xml_files(child)) + + return xml_files +``` + +## Verwendung + +### Automatische Hash-Berechnung + +Die Hash-Berechnung erfolgt automatisch beim Laden von Projekten. Keine manuelle Intervention erforderlich. + +### Neue XML-Dateien + +Beim Hinzufügen neuer XML-Dateien wird der Hash synchron berechnet: + +```python +def add_xml_file(self, xml_file: XmlFile): + if xml_file.hashsum is None: + xml_file.hashsum = self._calculate_hash_for_xml_file(xml_file) +``` + +## Fehlerbehandlung + +### Nicht existierende Dateien + +```python +def _calculate_blake2b_hash(self, file_path: Path) -> str | None: + try: + if not file_path.exists(): + return None + # ... Hash-Berechnung ... + except Exception as e: + self.error_occurred.emit(str(file_path), str(e)) + return None +``` + +### Thread-Sicherheit + +- Verwendung von Qt-Signalen für Thread-Kommunikation +- Keine direkten UI-Updates aus Worker-Thread +- Graceful handling von Thread-Fehlern + +## Performance-Optimierung + +### Asynchrone Verarbeitung + +- Hash-Berechnung läuft im Hintergrund +- UI bleibt responsiv während der Berechnung +- Batch-Verarbeitung mehrerer Dateien + +### Selektive Berechnung + +- Nur Dateien ohne bestehende `hashsum` werden verarbeitet +- Vermeidung redundanter Berechnungen +- Effiziente Speichernutzung + +## Testen + +### Test-Skript + +Ein umfassendes Test-Skript (`test_hash_implementation.py`) validiert: + +- Korrekte Hash-Berechnung +- XmlFile-Modell-Funktionalität +- Hash-Konsistenz +- Fehlerbehandlung + +### Ausführung + +```bash +uv run python test_hash_implementation.py +``` + +## Wartung und Erweiterung + +### Hinzufügung neuer Hash-Algorithmen + +Die Implementierung kann einfach erweitert werden: + +```python +def _calculate_hash(self, file_path: Path, algorithm: str = "blake2b") -> str | None: + if algorithm == "blake2b": + return self._calculate_blake2b_hash(file_path) + elif algorithm == "sha256": + return self._calculate_sha256_hash(file_path) + # ... weitere Algorithmen +``` + +### Konfiguration + +Hash-Einstellungen können über die Anwendungskonfiguration gesteuert werden: + +```python +class HashConfig: + algorithm: str = "blake2b" + prefix_format: str = "{algorithm}:" + async_calculation: bool = True +``` + +## Bekannte Einschränkungen + +1. **Dateigröße**: Sehr große XML-Dateien können die Hash-Berechnung verlangsamen +2. **Netzwerk-Dateien**: Dateien auf Netzlaufwerken werden möglicherweise langsamer verarbeitet +3. **Encoding**: Binärer Dateizugriff für konsistente Hash-Berechnung + +## Changelog + +### Version 1.0.0 +- Initiale Implementierung der blake2b-Hash-Berechnung +- Asynchrone Verarbeitung mit QThread +- Integration in Projektlade-Prozess +- Umfassende Fehlerbehandlung +- Test-Suite für Validierung \ No newline at end of file diff --git a/src/conf.py b/src/conf.py index 6831a1a..3ef661c 100644 --- a/src/conf.py +++ b/src/conf.py @@ -169,6 +169,8 @@ app_settings = AppSettings() class XmlFile(BaseModel): xml: Path + # blake2b hashsum + hashsum: str | None = None class XslFile(BaseModel): diff --git a/src/ui/MainWindow.py b/src/ui/MainWindow.py index 62573f0..7412c6d 100644 --- a/src/ui/MainWindow.py +++ b/src/ui/MainWindow.py @@ -3,8 +3,12 @@ import os import time import polars as pl import shutil +import hashlib +import logging +from concurrent.futures import ThreadPoolExecutor +from typing import List -from PySide6.QtCore import Qt, QSize +from PySide6.QtCore import Qt, QSize, QThread, Signal from PySide6.QtGui import QCursor, QPixmap, QPainter, QAction, QIcon, QDragEnterEvent, QDropEvent from PySide6.QtWidgets import QLabel, QMainWindow, QApplication, QStyleFactory, QMenu, QTreeWidgetItem, QMessageBox, QFileDialog from PySide6.QtPdf import QPdfDocument @@ -19,6 +23,96 @@ from conf import app_settings, Project, ProjectData, TreeNode, XslFile, XmlFile from pathlib import Path +logger = logging.getLogger(__name__) + + +class XmlHashCalculatorThread(QThread): + """ + Thread für die asynchrone Berechnung von blake2b-Hash-Werten für XML-Dateien. + """ + + # Signale für die Kommunikation mit dem Haupt-Thread + hash_calculated = Signal(object, str) # xml_file_object, hash_value + calculation_finished = Signal(int, int) # processed_count, total_count + error_occurred = Signal(str, str) # xml_file_path, error_message + + def __init__(self, project_dir: Path, xml_files: List[XmlFile]): + """ + Initialisiert den Hash-Berechnungs-Thread. + + Args: + project_dir: Pfad zum Projekt-Verzeichnis + xml_files: Liste der XmlFile-Objekte für die Hash-Berechnung + """ + super().__init__() + self.project_dir = project_dir + self.xml_files = xml_files + self.processed_count = 0 + + def run(self): + """ + Führt die Hash-Berechnung für alle XML-Dateien aus. + """ + logger.info(f"Starte Hash-Berechnung für {len(self.xml_files)} XML-Dateien") + + for xml_file in self.xml_files: + try: + # Prüfe ob hashsum bereits vorhanden ist + if xml_file.hashsum: + logger.debug(f"Hash bereits vorhanden für {xml_file.xml}: {xml_file.hashsum}") + self.processed_count += 1 + continue + + # Berechne Hash für die XML-Datei + xml_file_path = self.project_dir / xml_file.xml + hash_value = self._calculate_blake2b_hash(xml_file_path) + + if hash_value: + # Sende Signal mit berechnetem Hash + self.hash_calculated.emit(xml_file, hash_value) + logger.debug(f"Hash berechnet für {xml_file.xml}: {hash_value}") + + self.processed_count += 1 + + except Exception as e: + error_msg = f"Fehler bei Hash-Berechnung für {xml_file.xml}: {str(e)}" + logger.error(error_msg) + self.error_occurred.emit(str(xml_file.xml), error_msg) + self.processed_count += 1 + + # Sende Abschluss-Signal + self.calculation_finished.emit(self.processed_count, len(self.xml_files)) + logger.info(f"Hash-Berechnung abgeschlossen: {self.processed_count}/{len(self.xml_files)} verarbeitet") + + def _calculate_blake2b_hash(self, file_path: Path) -> str | None: + """ + Berechnet den blake2b-Hash einer XML-Datei. + + Args: + file_path: Pfad zur XML-Datei + + Returns: + str: Hash-Wert mit "blake2b:" Präfix oder None bei Fehler + """ + try: + if not file_path.exists(): + logger.warning(f"XML-Datei nicht gefunden: {file_path}") + return None + + # Datei binär lesen und Hash berechnen + with open(file_path, 'rb') as f: + file_content = f.read() + hash_obj = hashlib.blake2b(file_content) + hash_hex = hash_obj.hexdigest() + + # Präfix hinzufügen + return f"blake2b:{hash_hex}" + + except Exception as e: + logger.error(f"Fehler beim Berechnen des Hash für {file_path}: {e}") + return None + + class MainWindow(QMainWindow): def __init__(self, parent=None): """ @@ -65,6 +159,9 @@ class MainWindow(QMainWindow): # Das aktuelle ProjectData self.pdf_project = None + # Hash-Berechnungs-Thread + self.hash_calculator_thread = None + # Theme-Menü initialisieren self._setup_theme_menu() @@ -181,6 +278,9 @@ class MainWindow(QMainWindow): # Lade die Nodes in das TreeWidget self._load_nodes_to_tree() + # Starte Hash-Berechnung für alle XML-Dateien + self._start_xml_hash_calculation() + except Exception as e: print(f"Fehler beim Laden des Projekts '{project.name}': {e}") # Fallback: Erstelle Standard-Einstellungen @@ -1227,6 +1327,9 @@ class MainWindow(QMainWindow): print(f"XML-Datei '{xml_file_path.name}' zu XslFile-Node '{xsl_node.bez}' hinzugefügt") + # Berechne Hash für die neue XML-Datei + self._calculate_hash_for_xml_file(new_xml_file) + # Speichere die aktualisierten Projekt-Einstellungen self._save_project_settings() @@ -1974,6 +2077,10 @@ class MainWindow(QMainWindow): if not existing_xml: # Erstelle neues XmlFile-Objekt und füge es zur XslFile-Node hinzu new_xml_file = XmlFile(xml=relative_xml_path) + + # Berechne Hash für die neue XML-Datei + self._calculate_hash_for_xml_file(new_xml_file) + xsl_node.xmls.append(new_xml_file) added_count += 1 print(f"XML-Datei '{xml_file_path.name}' zu XslFile-Node '{xsl_node.bez}' hinzugefügt") @@ -2005,7 +2112,167 @@ class MainWindow(QMainWindow): print(error_msg) QMessageBox.critical(self, "Fehler", error_msg) + def _start_xml_hash_calculation(self): + """ + Startet die asynchrone Hash-Berechnung für alle XML-Dateien im Projekt. + """ + try: + if not hasattr(self, 'pdf_project') or not self.pdf_project: + logger.debug("Keine Projekt-Einstellungen verfügbar für Hash-Berechnung") + return + + # Sammle alle XML-Dateien aus dem Projekt + xml_files = self._collect_all_xml_files() + + if not xml_files: + logger.debug("Keine XML-Dateien für Hash-Berechnung gefunden") + return + + logger.info(f"Starte Hash-Berechnung für {len(xml_files)} XML-Dateien") + + # Stoppe vorherigen Thread falls noch aktiv + if self.hash_calculator_thread and self.hash_calculator_thread.isRunning(): + self.hash_calculator_thread.quit() + self.hash_calculator_thread.wait() + + # Erstelle und starte neuen Hash-Berechnungs-Thread + self.hash_calculator_thread = XmlHashCalculatorThread( + project_dir=Path(self.project.project_dir), + xml_files=xml_files + ) + + # Verbinde Signale + self.hash_calculator_thread.hash_calculated.connect(self._on_hash_calculated) + self.hash_calculator_thread.calculation_finished.connect(self._on_hash_calculation_finished) + self.hash_calculator_thread.error_occurred.connect(self._on_hash_calculation_error) + + # Starte Thread + self.hash_calculator_thread.start() + + except Exception as e: + logger.error(f"Fehler beim Starten der Hash-Berechnung: {e}") + + def _collect_all_xml_files(self) -> List[XmlFile]: + """ + Sammelt alle XmlFile-Objekte aus der Projektstruktur. + + Returns: + List[XmlFile]: Liste aller gefundenen XML-Dateien + """ + xml_files = [] + + try: + if self.pdf_project and self.pdf_project.nodes: + self._collect_xml_files_recursive(self.pdf_project.nodes, xml_files) + + logger.debug(f"Gesammelt: {len(xml_files)} XML-Dateien") + return xml_files + + except Exception as e: + logger.error(f"Fehler beim Sammeln der XML-Dateien: {e}") + return [] + + def _collect_xml_files_recursive(self, nodes, xml_files: List[XmlFile]): + """ + Sammelt rekursiv alle XML-Dateien aus den Nodes. + + Args: + nodes: Liste der zu durchsuchenden Nodes + xml_files: Liste zum Sammeln der XML-Dateien + """ + for node in nodes: + if isinstance(node, XslFile) and node.xmls: + # Füge alle XML-Dateien dieser XSL-Datei hinzu + for xml_file in node.xmls: + if xml_file not in xml_files: # Vermeide Duplikate + xml_files.append(xml_file) + elif isinstance(node, TreeNode) and node.children: + # Rekursiv in Kinder-Nodes suchen + self._collect_xml_files_recursive(node.children, xml_files) + + def _on_hash_calculated(self, xml_file: XmlFile, hash_value: str): + """ + Wird aufgerufen, wenn ein Hash-Wert berechnet wurde. + + Args: + xml_file: Das XmlFile-Objekt + hash_value: Der berechnete Hash-Wert mit Präfix + """ + try: + # Setze den Hash-Wert + xml_file.hashsum = hash_value + logger.debug(f"Hash gesetzt für {xml_file.xml}: {hash_value}") + + except Exception as e: + logger.error(f"Fehler beim Setzen des Hash-Werts: {e}") + + def _on_hash_calculation_finished(self, processed_count: int, total_count: int): + """ + Wird aufgerufen, wenn die Hash-Berechnung abgeschlossen ist. + + Args: + processed_count: Anzahl der verarbeiteten Dateien + total_count: Gesamtanzahl der Dateien + """ + try: + logger.info(f"Hash-Berechnung abgeschlossen: {processed_count}/{total_count} Dateien verarbeitet") + + # Speichere die aktualisierten Projekt-Einstellungen + if processed_count > 0: + self._save_project_settings() + logger.info("Projekt-Einstellungen mit neuen Hash-Werten gespeichert") + + except Exception as e: + logger.error(f"Fehler beim Abschließen der Hash-Berechnung: {e}") + + def _on_hash_calculation_error(self, xml_file_path: str, error_message: str): + """ + Wird aufgerufen, wenn ein Fehler bei der Hash-Berechnung auftritt. + + Args: + xml_file_path: Pfad zur XML-Datei + error_message: Fehlermeldung + """ + logger.warning(f"Hash-Berechnungsfehler für {xml_file_path}: {error_message}") + + def _calculate_hash_for_xml_file(self, xml_file: XmlFile): + """ + Berechnet synchron den Hash für eine einzelne XML-Datei. + Wird verwendet beim Hinzufügen neuer XML-Dateien. + + Args: + xml_file: Das XmlFile-Objekt + """ + try: + if xml_file.hashsum: + logger.debug(f"Hash bereits vorhanden für {xml_file.xml}: {xml_file.hashsum}") + return + + xml_file_path = Path(self.project.project_dir) / xml_file.xml + + if not xml_file_path.exists(): + logger.warning(f"XML-Datei nicht gefunden: {xml_file_path}") + return + + # Datei binär lesen und Hash berechnen + with open(xml_file_path, 'rb') as f: + file_content = f.read() + hash_obj = hashlib.blake2b(file_content) + hash_hex = hash_obj.hexdigest() + + # Hash mit Präfix setzen + xml_file.hashsum = f"blake2b:{hash_hex}" + logger.debug(f"Hash berechnet für {xml_file.xml}: {xml_file.hashsum}") + + except Exception as e: + logger.error(f"Fehler beim Berechnen des Hash für {xml_file.xml}: {e}") + def closeEvent(self, event): """Wird beim Schließen der Anwendung aufgerufen.""" + # Stoppe Hash-Berechnungs-Thread falls noch aktiv + if hasattr(self, 'hash_calculator_thread') and self.hash_calculator_thread and self.hash_calculator_thread.isRunning(): + self.hash_calculator_thread.quit() + self.hash_calculator_thread.wait() + # PDF-Dokumente schließen ist bei QtPdf automatisch durch Garbage Collection super().closeEvent(event) diff --git a/test_hash_implementation.py b/test_hash_implementation.py new file mode 100644 index 0000000..c0b9ccb --- /dev/null +++ b/test_hash_implementation.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +Test-Skript für die blake2b-Hash-Implementierung. +Testet die Hash-Berechnung für XML-Dateien. +""" + +import hashlib +import tempfile +from pathlib import Path +import sys +import os + +# Füge src-Verzeichnis zum Python-Pfad hinzu +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +from conf import XmlFile + +def test_blake2b_hash_calculation(): + """Testet die blake2b-Hash-Berechnung.""" + print("=== Test: blake2b-Hash-Berechnung ===") + + # Erstelle temporäre XML-Testdatei + test_xml_content = """ + + Test-Inhalt für Hash-Berechnung + Weitere Testdaten +""" + + with tempfile.NamedTemporaryFile(mode='w', suffix='.xml', delete=False, encoding='utf-8') as f: + f.write(test_xml_content) + temp_xml_path = Path(f.name) + + try: + # Berechne Hash manuell + with open(temp_xml_path, 'rb') as f: + file_content = f.read() + hash_obj = hashlib.blake2b(file_content) + expected_hash = f"blake2b:{hash_obj.hexdigest()}" + + print(f"Temporäre XML-Datei: {temp_xml_path}") + print(f"Erwarteter Hash: {expected_hash}") + + # Teste XmlFile-Objekt + xml_file = XmlFile(xml=temp_xml_path) + print(f"XmlFile erstellt: {xml_file.xml}") + print(f"Initiale hashsum: {xml_file.hashsum}") + + # Simuliere Hash-Berechnung + xml_file.hashsum = expected_hash + print(f"Hash gesetzt: {xml_file.hashsum}") + + # Verifikation + assert xml_file.hashsum == expected_hash, "Hash-Wert stimmt nicht überein!" + assert xml_file.hashsum.startswith("blake2b:"), "Hash-Präfix fehlt!" + + print("[OK] Test erfolgreich: Hash-Berechnung funktioniert korrekt") + + finally: + # Aufräumen + if temp_xml_path.exists(): + temp_xml_path.unlink() + +def test_xml_file_model(): + """Testet das XmlFile-Modell.""" + print("\n=== Test: XmlFile-Modell ===") + + # Test 1: XmlFile ohne hashsum + xml_file1 = XmlFile(xml=Path("test.xml")) + print(f"XmlFile ohne hashsum: {xml_file1}") + print(f"hashsum ist None: {xml_file1.hashsum is None}") + + # Test 2: XmlFile mit hashsum + test_hash = "blake2b:1234567890abcdef" + xml_file2 = XmlFile(xml=Path("test2.xml"), hashsum=test_hash) + print(f"XmlFile mit hashsum: {xml_file2}") + print(f"hashsum: {xml_file2.hashsum}") + + # Verifikation + assert xml_file1.hashsum is None, "Initiale hashsum sollte None sein" + assert xml_file2.hashsum == test_hash, "Gesetzte hashsum stimmt nicht überein" + + print("[OK] Test erfolgreich: XmlFile-Modell funktioniert korrekt") + +def test_hash_consistency(): + """Testet die Konsistenz der Hash-Berechnung.""" + print("\n=== Test: Hash-Konsistenz ===") + + test_content = b"Test content for consistency check" + + # Berechne Hash mehrmals + hash1 = hashlib.blake2b(test_content).hexdigest() + hash2 = hashlib.blake2b(test_content).hexdigest() + hash3 = hashlib.blake2b(test_content).hexdigest() + + print(f"Hash 1: {hash1}") + print(f"Hash 2: {hash2}") + print(f"Hash 3: {hash3}") + + # Verifikation + assert hash1 == hash2 == hash3, "Hash-Werte sind nicht konsistent!" + + # Test mit unterschiedlichem Inhalt + different_content = b"Different content" + hash_different = hashlib.blake2b(different_content).hexdigest() + + print(f"Hash für anderen Inhalt: {hash_different}") + assert hash1 != hash_different, "Verschiedene Inhalte sollten verschiedene Hashes haben!" + + print("[OK] Test erfolgreich: Hash-Berechnung ist konsistent") + +if __name__ == "__main__": + print("Starte Tests für blake2b-Hash-Implementierung...\n") + + try: + test_blake2b_hash_calculation() + test_xml_file_model() + test_hash_consistency() + + print("\n[SUCCESS] Alle Tests erfolgreich abgeschlossen!") + print("\nDie blake2b-Hash-Implementierung ist bereit für den Einsatz.") + + except Exception as e: + print(f"\n[ERROR] Test fehlgeschlagen: {e}") + sys.exit(1) \ No newline at end of file