XSL-Transformations-Engine mit Saxon, Apache FOP und diff-pdf implementiert
Fügt die komplette Transformations-Pipeline hinzu: - Saxon XSLT-Transformation (XML → FO) mit vollständigem Classpath-Support - Apache FOP PDF-Generierung (FO → PDF) mit plattformübergreifender Unterstützung - Automatische diff-pdf Vergleichs- und Diff-Generierung - Valide-PDF-Verwaltung (Referenz-PDFs beim ersten erfolgreichen Build) - Up-to-Date-Prüfung basierend auf Datei-Zeitstempeln - Asynchrone Ausführung via TransformationThread (QThread) - Kontextmenü-Integration für XML- und XSL-Dateien - Detailliertes Fehler-Reporting und Fortschritts-Feedback Neue Dateien: - src/transform.py: TransformationJob-Klasse mit vollständiger Pipeline Erweiterte Dateien: - src/ui/MainWindow.py: TransformationThread und Transformations-Methoden Technische Details: - Löst Saxon ClassNotFoundException durch Verwendung aller JARs im Saxon-Verzeichnis - Verwendet -cp statt -jar für vollständigen Classpath-Zugriff - Automatisches Cleanup temporärer FO-Dateien - Thread-sicheres Shutdown-Handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -20,4 +20,6 @@ dependencies = [
|
||||
line-length = 120
|
||||
|
||||
[dependency-groups]
|
||||
dev = []
|
||||
dev = [
|
||||
"ruff>=0.14.8",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
"""
|
||||
Transformations-Engine für XSL-FO PDF-Generierung.
|
||||
|
||||
Dieses Modul implementiert die Transformations-Pipeline:
|
||||
1. XML → FO (Saxon XSLT Transformation)
|
||||
2. FO → PDF (Apache FOP)
|
||||
3. PDF-Vergleich (diff-pdf)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TransformationJob:
|
||||
"""
|
||||
Repräsentiert einen einzelnen Transformations-Job.
|
||||
|
||||
Ähnlich zur TestFall-Klasse in validate-xls.py, aber für DocuMentor angepasst.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_dir: Path,
|
||||
xml_file: Path,
|
||||
xsl_file: Path,
|
||||
xslt_params: dict[str, str],
|
||||
java_vm_path: Path,
|
||||
saxon_jar_path: Path,
|
||||
apache_fop_dir: Path,
|
||||
diff_pdf_path: Path,
|
||||
diff_pdf_params: list[str]
|
||||
):
|
||||
"""
|
||||
Initialisiert einen Transformations-Job.
|
||||
|
||||
Args:
|
||||
project_dir: Pfad zum Projekt-Verzeichnis
|
||||
xml_file: Relative Pfad zur XML-Eingabedatei (relativ zu project_dir)
|
||||
xsl_file: Absolute Pfad zur XSL-Stylesheet-Datei
|
||||
xslt_params: Dictionary mit XSLT-Parametern
|
||||
java_vm_path: Pfad zur Java VM Binary
|
||||
saxon_jar_path: Pfad zur Saxon JAR-Datei
|
||||
apache_fop_dir: Pfad zum Apache FOP-Verzeichnis
|
||||
diff_pdf_path: Pfad zur diff-pdf Binary
|
||||
diff_pdf_params: Standard-Parameter für diff-pdf
|
||||
"""
|
||||
self.project_dir = project_dir
|
||||
self.xml_file = xml_file # Relativ
|
||||
self.xsl_file = xsl_file # Absolut
|
||||
self.xslt_params = xslt_params
|
||||
|
||||
# Tool-Pfade
|
||||
self.java_vm_path = java_vm_path
|
||||
self.saxon_jar_path = saxon_jar_path
|
||||
self.apache_fop_dir = apache_fop_dir
|
||||
self.diff_pdf_path = diff_pdf_path
|
||||
self.diff_pdf_params = diff_pdf_params
|
||||
|
||||
# Ausgabe-Verzeichnisse im Projektordner
|
||||
self.output_dir = project_dir / "output"
|
||||
self.valide_dir = project_dir / "valide"
|
||||
self.diff_dir = project_dir / "diff"
|
||||
|
||||
# Stelle sicher, dass Ausgabe-Verzeichnisse existieren
|
||||
self.output_dir.mkdir(exist_ok=True)
|
||||
self.valide_dir.mkdir(exist_ok=True)
|
||||
self.diff_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Dateinamen basierend auf XML-Datei
|
||||
base_name = self.xml_file.stem
|
||||
self.temp_fo = self.output_dir / f"{base_name}.fo"
|
||||
self.output_pdf = self.output_dir / f"{base_name}.pdf"
|
||||
self.valide_pdf = self.valide_dir / f"{base_name}.pdf"
|
||||
self.diff_pdf = self.diff_dir / f"{base_name}.pdf"
|
||||
|
||||
# Apache FOP Binaries (plattformabhängig)
|
||||
import sys
|
||||
if sys.platform == "win32":
|
||||
self.fop_cmd = self.apache_fop_dir / "fop.cmd"
|
||||
else:
|
||||
self.fop_cmd = self.apache_fop_dir / "fop"
|
||||
|
||||
self.fop_conf = self.apache_fop_dir / "conf" / "fop.xconf"
|
||||
|
||||
def is_up_to_date(self) -> bool:
|
||||
"""
|
||||
Prüft, ob die Transformation aktuell ist.
|
||||
|
||||
Returns:
|
||||
bool: True wenn Output-PDF existiert und aktueller ist als alle Inputs
|
||||
"""
|
||||
if not self.output_pdf.exists():
|
||||
logger.debug(f"Output-PDF existiert nicht: {self.output_pdf}")
|
||||
return False
|
||||
|
||||
output_mtime = self.output_pdf.stat().st_mtime
|
||||
|
||||
# Prüfe XML-Datei
|
||||
xml_abs = self.project_dir / self.xml_file
|
||||
if xml_abs.exists() and xml_abs.stat().st_mtime > output_mtime:
|
||||
logger.debug(f"XML-Datei ist neuer: {xml_abs}")
|
||||
return False
|
||||
|
||||
# Prüfe XSL-Datei
|
||||
if self.xsl_file.exists() and self.xsl_file.stat().st_mtime > output_mtime:
|
||||
logger.debug(f"XSL-Datei ist neuer: {self.xsl_file}")
|
||||
return False
|
||||
|
||||
logger.debug(f"Transformation ist aktuell: {self.output_pdf}")
|
||||
return True
|
||||
|
||||
def transform_saxon(self, force: bool = False) -> tuple[bool, str]:
|
||||
"""
|
||||
Führt XSLT-Transformation mit Saxon aus: XML → FO.
|
||||
|
||||
Args:
|
||||
force: Wenn True, wird Transformation auch bei aktuellem Output durchgeführt
|
||||
|
||||
Returns:
|
||||
tuple[bool, str]: (Erfolg, Fehlermeldung/Info)
|
||||
"""
|
||||
if not force and self.is_up_to_date():
|
||||
logger.info(f"Transformation übersprungen (aktuell): {self.xml_file.name}")
|
||||
return True, "Übersprungen (aktuell)"
|
||||
|
||||
xml_abs = self.project_dir / self.xml_file
|
||||
|
||||
# Prüfe ob Eingabedateien existieren
|
||||
if not xml_abs.exists():
|
||||
error_msg = f"XML-Datei nicht gefunden: {xml_abs}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
if not self.xsl_file.exists():
|
||||
error_msg = f"XSL-Datei nicht gefunden: {self.xsl_file}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
# XSLT-Parameter formatieren
|
||||
params = [f"{key}={value}" for key, value in self.xslt_params.items()]
|
||||
|
||||
# Sammle alle JAR-Dateien im Saxon-Verzeichnis für den Classpath
|
||||
import glob
|
||||
saxon_dir = self.saxon_jar_path.parent
|
||||
all_jars = glob.glob(str(saxon_dir / "*.jar"))
|
||||
|
||||
# Verwende alle JARs im Classpath (getrennt durch : auf Linux/Mac, ; auf Windows)
|
||||
import sys
|
||||
classpath_separator = ";" if sys.platform == "win32" else ":"
|
||||
classpath = classpath_separator.join(all_jars)
|
||||
|
||||
# Saxon-Kommandozeile
|
||||
# Verwende -cp mit allen JARs und rufe Transform-Main direkt auf
|
||||
cmd_line = [
|
||||
str(self.java_vm_path),
|
||||
"-cp",
|
||||
classpath,
|
||||
"net.sf.saxon.Transform",
|
||||
f"-s:{xml_abs}",
|
||||
f"-xsl:{self.xsl_file}",
|
||||
f"-o:{self.temp_fo}",
|
||||
*params,
|
||||
]
|
||||
|
||||
logger.info(f"Starte Saxon-Transformation: {self.xml_file.name}")
|
||||
logger.debug(f"Kommandozeile: {' '.join(cmd_line)}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd_line,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120 # 2 Minuten Timeout
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.info(f"Saxon-Transformation erfolgreich: {self.xml_file.name}")
|
||||
return True, "Erfolgreich"
|
||||
else:
|
||||
error_msg = f"Saxon-Fehler (Exit {result.returncode}):\nStdOut: {result.stdout}\nStdErr: {result.stderr}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
error_msg = "Saxon-Transformation Timeout (>120s)"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
except Exception as e:
|
||||
error_msg = f"Unerwarteter Fehler bei Saxon-Transformation: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
def build_pdf(self, force: bool = False) -> tuple[bool, str]:
|
||||
"""
|
||||
Generiert PDF aus FO-Datei mit Apache FOP: FO → PDF.
|
||||
|
||||
Args:
|
||||
force: Wenn True, wird Build auch bei aktuellem Output durchgeführt
|
||||
|
||||
Returns:
|
||||
tuple[bool, str]: (Erfolg, Fehlermeldung/Info)
|
||||
"""
|
||||
if not force and self.is_up_to_date():
|
||||
logger.info(f"PDF-Build übersprungen (aktuell): {self.xml_file.name}")
|
||||
return True, "Übersprungen (aktuell)"
|
||||
|
||||
# Prüfe ob FO-Datei existiert
|
||||
if not self.temp_fo.exists():
|
||||
error_msg = f"FO-Datei nicht gefunden: {self.temp_fo}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
# Apache FOP Kommandozeile
|
||||
cmd_line = [
|
||||
str(self.fop_cmd),
|
||||
"-c", str(self.fop_conf) if self.fop_conf.exists() else "",
|
||||
"-r",
|
||||
"-fo", str(self.temp_fo),
|
||||
"-pdf", str(self.output_pdf),
|
||||
]
|
||||
|
||||
# Entferne leere Config-Parameter wenn fop.xconf nicht existiert
|
||||
if not self.fop_conf.exists():
|
||||
cmd_line = [c for c in cmd_line if c not in ["-c", ""]]
|
||||
|
||||
logger.info(f"Starte Apache FOP PDF-Generierung: {self.xml_file.name}")
|
||||
logger.debug(f"Kommandozeile: {' '.join(cmd_line)}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd_line,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180 # 3 Minuten Timeout
|
||||
)
|
||||
|
||||
# Temporäre FO-Datei löschen
|
||||
if self.temp_fo.exists():
|
||||
try:
|
||||
self.temp_fo.unlink()
|
||||
logger.debug(f"Temporäre FO-Datei gelöscht: {self.temp_fo}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Konnte FO-Datei nicht löschen: {e}")
|
||||
|
||||
if result.returncode == 0:
|
||||
# Wenn kein Valide-PDF existiert, erstelle es
|
||||
if not self.valide_pdf.exists():
|
||||
try:
|
||||
import shutil
|
||||
shutil.copy2(self.output_pdf, self.valide_pdf)
|
||||
logger.info(f"Valide-PDF erstellt: {self.valide_pdf}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Konnte Valide-PDF nicht erstellen: {e}")
|
||||
|
||||
logger.info(f"PDF-Generierung erfolgreich: {self.output_pdf}")
|
||||
return True, "Erfolgreich"
|
||||
else:
|
||||
error_msg = f"FOP-Fehler (Exit {result.returncode}):\nStdOut: {result.stdout}\nStdErr: {result.stderr}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
error_msg = "FOP PDF-Generierung Timeout (>180s)"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
except Exception as e:
|
||||
error_msg = f"Unerwarteter Fehler bei PDF-Generierung: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
def compare_pdf(self) -> tuple[bool, str]:
|
||||
"""
|
||||
Vergleicht Output-PDF mit Valide-PDF und erstellt ggf. Diff-PDF.
|
||||
|
||||
Returns:
|
||||
tuple[bool, str]: (PDFs sind identisch, Fehlermeldung/Info)
|
||||
"""
|
||||
# Prüfe ob beide PDFs existieren
|
||||
if not self.valide_pdf.exists():
|
||||
info_msg = "Kein Valide-PDF vorhanden (wird beim nächsten Build erstellt)"
|
||||
logger.info(info_msg)
|
||||
return True, info_msg
|
||||
|
||||
if not self.output_pdf.exists():
|
||||
error_msg = f"Output-PDF nicht gefunden: {self.output_pdf}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
logger.info(f"Vergleiche PDFs: {self.xml_file.name}")
|
||||
|
||||
# Erster Vergleich (ohne Diff-Generierung)
|
||||
cmd_compare = [
|
||||
str(self.diff_pdf_path),
|
||||
*self.diff_pdf_params,
|
||||
str(self.valide_pdf),
|
||||
str(self.output_pdf),
|
||||
]
|
||||
|
||||
logger.debug(f"Kommandozeile Vergleich: {' '.join(cmd_compare)}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd_compare,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60 # 1 Minute Timeout
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# PDFs sind identisch
|
||||
logger.info(f"PDFs sind identisch: {self.xml_file.name}")
|
||||
|
||||
# Lösche altes Diff-PDF falls vorhanden
|
||||
if self.diff_pdf.exists():
|
||||
try:
|
||||
self.diff_pdf.unlink()
|
||||
logger.debug(f"Diff-PDF gelöscht (nicht mehr nötig): {self.diff_pdf}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Konnte Diff-PDF nicht löschen: {e}")
|
||||
|
||||
return True, "PDFs sind identisch"
|
||||
else:
|
||||
# PDFs unterscheiden sich - erstelle Diff-PDF
|
||||
logger.info(f"PDFs unterscheiden sich, erstelle Diff-PDF: {self.xml_file.name}")
|
||||
|
||||
cmd_diff = [
|
||||
str(self.diff_pdf_path),
|
||||
f"--output-diff={self.diff_pdf}",
|
||||
*self.diff_pdf_params,
|
||||
"--mark-differences",
|
||||
str(self.valide_pdf),
|
||||
str(self.output_pdf),
|
||||
]
|
||||
|
||||
logger.debug(f"Kommandozeile Diff: {' '.join(cmd_diff)}")
|
||||
|
||||
result_diff = subprocess.run(
|
||||
cmd_diff,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=90 # 1.5 Minuten Timeout
|
||||
)
|
||||
|
||||
if result_diff.returncode == 0 or self.diff_pdf.exists():
|
||||
logger.info(f"Diff-PDF erstellt: {self.diff_pdf}")
|
||||
return False, f"Unterschiede gefunden - Diff-PDF: {self.diff_pdf.name}"
|
||||
else:
|
||||
error_msg = f"Diff-PDF-Erstellung fehlgeschlagen: {result_diff.stderr}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
error_msg = "PDF-Vergleich Timeout"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
except Exception as e:
|
||||
error_msg = f"Unerwarteter Fehler beim PDF-Vergleich: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
def run_full_pipeline(self, force: bool = False) -> dict[str, any]:
|
||||
"""
|
||||
Führt die komplette Transformations-Pipeline aus:
|
||||
1. Saxon-Transformation (XML → FO)
|
||||
2. PDF-Generierung (FO → PDF)
|
||||
3. PDF-Vergleich
|
||||
|
||||
Args:
|
||||
force: Wenn True, werden alle Schritte ausgeführt (ignoriert Up-to-Date)
|
||||
|
||||
Returns:
|
||||
dict: Ergebnis-Dictionary mit Status und Meldungen
|
||||
"""
|
||||
start_time = datetime.now()
|
||||
result = {
|
||||
"success": False,
|
||||
"xml_file": str(self.xml_file),
|
||||
"steps": {},
|
||||
"duration": None,
|
||||
"output_pdf": str(self.output_pdf) if self.output_pdf.exists() else None,
|
||||
"diff_pdf": str(self.diff_pdf) if self.diff_pdf.exists() else None,
|
||||
}
|
||||
|
||||
logger.info(f"Starte Transformations-Pipeline: {self.xml_file.name}")
|
||||
|
||||
# Schritt 1: Saxon-Transformation
|
||||
success_saxon, msg_saxon = self.transform_saxon(force=force)
|
||||
result["steps"]["saxon"] = {"success": success_saxon, "message": msg_saxon}
|
||||
|
||||
if not success_saxon:
|
||||
result["success"] = False
|
||||
result["duration"] = (datetime.now() - start_time).total_seconds()
|
||||
return result
|
||||
|
||||
# Schritt 2: PDF-Generierung
|
||||
success_build, msg_build = self.build_pdf(force=force)
|
||||
result["steps"]["build"] = {"success": success_build, "message": msg_build}
|
||||
|
||||
if not success_build:
|
||||
result["success"] = False
|
||||
result["duration"] = (datetime.now() - start_time).total_seconds()
|
||||
return result
|
||||
|
||||
# Schritt 3: PDF-Vergleich
|
||||
pdfs_identical, msg_compare = self.compare_pdf()
|
||||
result["steps"]["compare"] = {"identical": pdfs_identical, "message": msg_compare}
|
||||
result["pdfs_identical"] = pdfs_identical
|
||||
|
||||
# Pipeline erfolgreich abgeschlossen
|
||||
result["success"] = True
|
||||
result["duration"] = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
logger.info(f"Pipeline abgeschlossen: {self.xml_file.name} ({result['duration']:.2f}s)")
|
||||
|
||||
return result
|
||||
+360
-6
@@ -20,6 +20,7 @@ from ui.TreeNodeEditDialog import TreeNodeEditDialog
|
||||
from ui.XslFileEditDialog import XslFileEditDialog
|
||||
from ui.XmlToXslAssignDialog import XmlToXslAssignDialog
|
||||
from conf import app_settings, Project, ProjectData, TreeNode, XslFile, XmlFile
|
||||
from transform import TransformationJob
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -113,6 +114,60 @@ class XmlHashCalculatorThread(QThread):
|
||||
return None
|
||||
|
||||
|
||||
class TransformationThread(QThread):
|
||||
"""
|
||||
Thread für die asynchrone Ausführung von Transformations-Jobs.
|
||||
"""
|
||||
|
||||
# Signale für die Kommunikation mit dem Haupt-Thread
|
||||
job_started = Signal(str) # xml_file_name
|
||||
job_finished = Signal(dict) # result_dict
|
||||
job_error = Signal(str, str) # xml_file_name, error_message
|
||||
all_jobs_finished = Signal(int, int) # successful_count, total_count
|
||||
|
||||
def __init__(self, jobs: list[TransformationJob], force: bool = False):
|
||||
"""
|
||||
Initialisiert den Transformations-Thread.
|
||||
|
||||
Args:
|
||||
jobs: Liste der TransformationJob-Objekte
|
||||
force: Wenn True, werden alle Jobs ausgeführt (ignoriert Up-to-Date)
|
||||
"""
|
||||
super().__init__()
|
||||
self.jobs = jobs
|
||||
self.force = force
|
||||
self.successful_count = 0
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Führt alle Transformations-Jobs sequenziell aus.
|
||||
"""
|
||||
logger.info(f"Starte Transformation von {len(self.jobs)} Jobs")
|
||||
|
||||
for job in self.jobs:
|
||||
try:
|
||||
# Sende Start-Signal
|
||||
self.job_started.emit(str(job.xml_file))
|
||||
|
||||
# Führe Transformations-Pipeline aus
|
||||
result = job.run_full_pipeline(force=self.force)
|
||||
|
||||
# Sende Abschluss-Signal
|
||||
self.job_finished.emit(result)
|
||||
|
||||
if result["success"]:
|
||||
self.successful_count += 1
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Unerwarteter Fehler bei Transformation: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
self.job_error.emit(str(job.xml_file), error_msg)
|
||||
|
||||
# Sende Abschluss-Signal für alle Jobs
|
||||
self.all_jobs_finished.emit(self.successful_count, len(self.jobs))
|
||||
logger.info(f"Transformation abgeschlossen: {self.successful_count}/{len(self.jobs)} erfolgreich")
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self, parent=None):
|
||||
"""
|
||||
@@ -161,7 +216,10 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# Hash-Berechnungs-Thread
|
||||
self.hash_calculator_thread = None
|
||||
|
||||
|
||||
# Transformations-Thread
|
||||
self.transformation_thread = None
|
||||
|
||||
# Theme-Menü initialisieren
|
||||
self._setup_theme_menu()
|
||||
|
||||
@@ -856,14 +914,27 @@ class MainWindow(QMainWindow):
|
||||
action_add_xml.setIcon(QIcon(QIcon.fromTheme("document-new")))
|
||||
action_add_xml.triggered.connect(lambda: self._add_xml_file_to_xsl(item))
|
||||
menu.addAction(action_add_xml)
|
||||
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
|
||||
# Transformations-Aktionen
|
||||
action_transform = QAction("Alle XML-Dateien transformieren", self)
|
||||
action_transform.setIcon(QIcon(QIcon.fromTheme("system-run")))
|
||||
action_transform.triggered.connect(lambda: self._transform_xsl_file(item))
|
||||
menu.addAction(action_transform)
|
||||
|
||||
action_transform_force = QAction("Alle XML-Dateien neu transformieren (force)", self)
|
||||
action_transform_force.setIcon(QIcon(QIcon.fromTheme("view-refresh")))
|
||||
action_transform_force.triggered.connect(lambda: self._transform_xsl_file(item, force=True))
|
||||
menu.addAction(action_transform_force)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
action_edit = QAction("Bearbeiten", self)
|
||||
action_edit.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.DocumentProperties)))
|
||||
action_edit.triggered.connect(lambda: self._edit_xsl_file(item))
|
||||
menu.addAction(action_edit)
|
||||
|
||||
|
||||
action_delete = QAction("Löschen", self)
|
||||
action_delete.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.EditDelete)))
|
||||
action_delete.triggered.connect(lambda: self._delete_xsl_file(item))
|
||||
@@ -871,11 +942,24 @@ class MainWindow(QMainWindow):
|
||||
|
||||
elif node_type == "XmlFile":
|
||||
# Kontextmenü für XmlFile
|
||||
# Transformations-Aktionen
|
||||
action_transform = QAction("Transformieren", self)
|
||||
action_transform.setIcon(QIcon(QIcon.fromTheme("system-run")))
|
||||
action_transform.triggered.connect(lambda: self._transform_xml_file(item))
|
||||
menu.addAction(action_transform)
|
||||
|
||||
action_transform_force = QAction("Neu transformieren (force)", self)
|
||||
action_transform_force.setIcon(QIcon(QIcon.fromTheme("view-refresh")))
|
||||
action_transform_force.triggered.connect(lambda: self._transform_xml_file(item, force=True))
|
||||
menu.addAction(action_transform_force)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
action_edit = QAction("Bearbeiten", self)
|
||||
action_edit.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.DocumentProperties)))
|
||||
action_edit.triggered.connect(lambda: self._edit_xml_file(item))
|
||||
menu.addAction(action_edit)
|
||||
|
||||
|
||||
action_delete = QAction("Löschen", self)
|
||||
action_delete.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.EditDelete)))
|
||||
action_delete.triggered.connect(lambda: self._delete_xml_file(item))
|
||||
@@ -2618,12 +2702,282 @@ class MainWindow(QMainWindow):
|
||||
# Fallback: Ersten alternativen Namen verwenden
|
||||
return alternative_paths[0] if alternative_paths else None
|
||||
|
||||
def _transform_xml_file(self, item: QTreeWidgetItem, force: bool = False):
|
||||
"""
|
||||
Transformiert eine einzelne XML-Datei.
|
||||
|
||||
Args:
|
||||
item: Das TreeWidgetItem der XML-Datei
|
||||
force: Wenn True, wird Transformation auch bei aktuellem Output durchgeführt
|
||||
"""
|
||||
try:
|
||||
# Hole XslFile vom Parent-Item
|
||||
parent_item = item.parent()
|
||||
if not parent_item:
|
||||
logger.error("XML-Datei hat kein Parent-Item (XslFile)")
|
||||
QMessageBox.warning(self, "Fehler", "XML-Datei hat keine zugeordnete XSL-Datei")
|
||||
return
|
||||
|
||||
xsl_file_obj = parent_item.data(0, Qt.ItemDataRole.UserRole)
|
||||
if not isinstance(xsl_file_obj, XslFile):
|
||||
logger.error(f"Parent-Item ist kein XslFile: {type(xsl_file_obj)}")
|
||||
QMessageBox.warning(self, "Fehler", "Konnte XSL-Datei nicht ermitteln")
|
||||
return
|
||||
|
||||
# Hole XmlFile-Objekt
|
||||
xml_file_obj = item.data(0, Qt.ItemDataRole.UserRole)
|
||||
if not isinstance(xml_file_obj, XmlFile):
|
||||
logger.error(f"Item ist kein XmlFile: {type(xml_file_obj)}")
|
||||
QMessageBox.warning(self, "Fehler", "Konnte XML-Datei nicht ermitteln")
|
||||
return
|
||||
|
||||
# Erstelle TransformationJob
|
||||
job = self._create_transformation_job(xsl_file_obj, xml_file_obj)
|
||||
if not job:
|
||||
return
|
||||
|
||||
# Starte Transformation in separatem Thread
|
||||
self._start_transformation([job], force=force)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Transformieren der XML-Datei: {e}")
|
||||
QMessageBox.critical(self, "Fehler", f"Fehler beim Transformieren: {str(e)}")
|
||||
|
||||
def _transform_xsl_file(self, item: QTreeWidgetItem, force: bool = False):
|
||||
"""
|
||||
Transformiert alle XML-Dateien einer XSL-Datei.
|
||||
|
||||
Args:
|
||||
item: Das TreeWidgetItem der XSL-Datei
|
||||
force: Wenn True, wird Transformation auch bei aktuellem Output durchgeführt
|
||||
"""
|
||||
try:
|
||||
# Hole XslFile-Objekt
|
||||
xsl_file_obj = item.data(0, Qt.ItemDataRole.UserRole)
|
||||
if not isinstance(xsl_file_obj, XslFile):
|
||||
logger.error(f"Item ist kein XslFile: {type(xsl_file_obj)}")
|
||||
QMessageBox.warning(self, "Fehler", "Konnte XSL-Datei nicht ermitteln")
|
||||
return
|
||||
|
||||
# Prüfe ob XML-Dateien vorhanden sind
|
||||
if not xsl_file_obj.xmls:
|
||||
QMessageBox.information(self, "Info", "Keine XML-Dateien zugeordnet")
|
||||
return
|
||||
|
||||
# Erstelle TransformationJobs für alle XML-Dateien
|
||||
jobs = []
|
||||
for xml_file_obj in xsl_file_obj.xmls:
|
||||
job = self._create_transformation_job(xsl_file_obj, xml_file_obj)
|
||||
if job:
|
||||
jobs.append(job)
|
||||
|
||||
if not jobs:
|
||||
QMessageBox.warning(self, "Fehler", "Konnte keine Transformations-Jobs erstellen")
|
||||
return
|
||||
|
||||
# Starte Transformation in separatem Thread
|
||||
self._start_transformation(jobs, force=force)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Transformieren der XSL-Datei: {e}")
|
||||
QMessageBox.critical(self, "Fehler", f"Fehler beim Transformieren: {str(e)}")
|
||||
|
||||
def _create_transformation_job(self, xsl_file_obj: XslFile, xml_file_obj: XmlFile) -> TransformationJob | None:
|
||||
"""
|
||||
Erstellt einen TransformationJob für eine XML/XSL-Kombination.
|
||||
|
||||
Args:
|
||||
xsl_file_obj: Das XslFile-Objekt
|
||||
xml_file_obj: Das XmlFile-Objekt
|
||||
|
||||
Returns:
|
||||
TransformationJob oder None bei Fehler
|
||||
"""
|
||||
try:
|
||||
if not self.project:
|
||||
QMessageBox.warning(self, "Fehler", "Kein Projekt geöffnet")
|
||||
return None
|
||||
|
||||
# Hole Tool-Konfigurationen aus app_settings
|
||||
java_vm = next((jvm for jvm in app_settings.java_vms if jvm.id == self.project.java_vm_id), None)
|
||||
saxon_jar = next((sj for sj in app_settings.saxon_jars if sj.id == self.project.saxon_jar_id), None)
|
||||
apache_fop = next((af for af in app_settings.apache_fops if af.id == self.project.apache_fop_id), None)
|
||||
diff_pdf = next((dp for dp in app_settings.diff_pdfs if dp.id == self.project.diff_pdf_id), None)
|
||||
xsl_dir = next((xd for xd in app_settings.xsl_dirs if xd.id == self.project.xsl_dir_id), None)
|
||||
|
||||
# Prüfe ob alle Konfigurationen vorhanden sind
|
||||
if not all([java_vm, saxon_jar, apache_fop, diff_pdf, xsl_dir]):
|
||||
missing = []
|
||||
if not java_vm: missing.append("Java VM")
|
||||
if not saxon_jar: missing.append("Saxon JAR")
|
||||
if not apache_fop: missing.append("Apache FOP")
|
||||
if not diff_pdf: missing.append("diff-pdf")
|
||||
if not xsl_dir: missing.append("XSL-Verzeichnis")
|
||||
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Fehlende Konfiguration",
|
||||
f"Folgende Konfigurationen fehlen: {', '.join(missing)}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Erstelle absoluten Pfad zur XSL-Datei
|
||||
xsl_file_abs = xsl_dir.path_to_root_dir / xsl_file_obj.xsl_file
|
||||
|
||||
# Sammle XSLT-Parameter (kombiniere TreeNode + XslFile Parameter)
|
||||
xslt_params = {}
|
||||
# TODO: Hier könnten TreeNode-Parameter von übergeordneten Knoten gesammelt werden
|
||||
xslt_params.update(xsl_file_obj.xslt_params)
|
||||
|
||||
# Erstelle TransformationJob
|
||||
job = TransformationJob(
|
||||
project_dir=self.project.project_dir,
|
||||
xml_file=xml_file_obj.xml,
|
||||
xsl_file=xsl_file_abs,
|
||||
xslt_params=xslt_params,
|
||||
java_vm_path=java_vm.path_to_binary_file,
|
||||
saxon_jar_path=saxon_jar.path_to_jar_file,
|
||||
apache_fop_dir=apache_fop.path_to_dir,
|
||||
diff_pdf_path=diff_pdf.path_to_binary_file,
|
||||
diff_pdf_params=diff_pdf.default_params
|
||||
)
|
||||
|
||||
return job
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Erstellen des TransformationJobs: {e}")
|
||||
QMessageBox.critical(self, "Fehler", f"Fehler beim Erstellen des Jobs: {str(e)}")
|
||||
return None
|
||||
|
||||
def _start_transformation(self, jobs: list[TransformationJob], force: bool = False):
|
||||
"""
|
||||
Startet die Transformation in einem separaten Thread.
|
||||
|
||||
Args:
|
||||
jobs: Liste der TransformationJobs
|
||||
force: Wenn True, werden alle Jobs ausgeführt (ignoriert Up-to-Date)
|
||||
"""
|
||||
try:
|
||||
# Prüfe ob bereits ein Thread läuft
|
||||
if self.transformation_thread and self.transformation_thread.isRunning():
|
||||
QMessageBox.warning(self, "Warnung", "Es läuft bereits eine Transformation")
|
||||
return
|
||||
|
||||
# Erstelle und konfiguriere Thread
|
||||
self.transformation_thread = TransformationThread(jobs, force=force)
|
||||
|
||||
# Verbinde Signale
|
||||
self.transformation_thread.job_started.connect(self._on_transformation_job_started)
|
||||
self.transformation_thread.job_finished.connect(self._on_transformation_job_finished)
|
||||
self.transformation_thread.job_error.connect(self._on_transformation_job_error)
|
||||
self.transformation_thread.all_jobs_finished.connect(self._on_all_transformations_finished)
|
||||
|
||||
# Starte Thread
|
||||
self.transformation_thread.start()
|
||||
|
||||
logger.info(f"Transformation von {len(jobs)} Job(s) gestartet (force={force})")
|
||||
self.statusBar().showMessage(f"Transformation von {len(jobs)} Job(s) gestartet...")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Starten der Transformation: {e}")
|
||||
QMessageBox.critical(self, "Fehler", f"Fehler beim Starten: {str(e)}")
|
||||
|
||||
def _on_transformation_job_started(self, xml_file_name: str):
|
||||
"""
|
||||
Signal-Handler: Ein Job wurde gestartet.
|
||||
|
||||
Args:
|
||||
xml_file_name: Name der XML-Datei
|
||||
"""
|
||||
logger.info(f"Transformation gestartet: {xml_file_name}")
|
||||
self.statusBar().showMessage(f"Transformiere: {xml_file_name}")
|
||||
|
||||
def _on_transformation_job_finished(self, result: dict):
|
||||
"""
|
||||
Signal-Handler: Ein Job wurde abgeschlossen.
|
||||
|
||||
Args:
|
||||
result: Ergebnis-Dictionary
|
||||
"""
|
||||
xml_file = result.get("xml_file", "?")
|
||||
success = result.get("success", False)
|
||||
duration = result.get("duration", 0)
|
||||
|
||||
if success:
|
||||
logger.info(f"Transformation erfolgreich: {xml_file} ({duration:.2f}s)")
|
||||
pdfs_identical = result.get("pdfs_identical", False)
|
||||
if pdfs_identical:
|
||||
self.statusBar().showMessage(f"✓ {xml_file} - PDFs identisch ({duration:.2f}s)", 3000)
|
||||
else:
|
||||
self.statusBar().showMessage(f"⚠ {xml_file} - Unterschiede gefunden ({duration:.2f}s)", 3000)
|
||||
else:
|
||||
logger.error(f"Transformation fehlgeschlagen: {xml_file}")
|
||||
# Zeige Fehlerdetails an
|
||||
steps = result.get("steps", {})
|
||||
error_msgs = []
|
||||
for step_name, step_info in steps.items():
|
||||
if not step_info.get("success", True):
|
||||
error_msgs.append(f"{step_name}: {step_info.get('message', 'Unbekannter Fehler')}")
|
||||
|
||||
error_text = "\n".join(error_msgs) if error_msgs else "Unbekannter Fehler"
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"Transformation fehlgeschlagen",
|
||||
f"XML-Datei: {xml_file}\n\nFehler:\n{error_text}"
|
||||
)
|
||||
|
||||
def _on_transformation_job_error(self, xml_file_name: str, error_message: str):
|
||||
"""
|
||||
Signal-Handler: Ein Job ist mit einem Fehler abgebrochen.
|
||||
|
||||
Args:
|
||||
xml_file_name: Name der XML-Datei
|
||||
error_message: Fehlermeldung
|
||||
"""
|
||||
logger.error(f"Transformation-Fehler bei {xml_file_name}: {error_message}")
|
||||
QMessageBox.critical(self, "Fehler", f"Fehler bei {xml_file_name}:\n{error_message}")
|
||||
|
||||
def _on_all_transformations_finished(self, successful_count: int, total_count: int):
|
||||
"""
|
||||
Signal-Handler: Alle Jobs wurden abgeschlossen.
|
||||
|
||||
Args:
|
||||
successful_count: Anzahl erfolgreicher Jobs
|
||||
total_count: Gesamtanzahl der Jobs
|
||||
"""
|
||||
logger.info(f"Alle Transformationen abgeschlossen: {successful_count}/{total_count} erfolgreich")
|
||||
|
||||
if successful_count == total_count:
|
||||
self.statusBar().showMessage(f"✓ Alle {total_count} Transformationen erfolgreich", 5000)
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Abgeschlossen",
|
||||
f"Alle {total_count} Transformationen wurden erfolgreich abgeschlossen"
|
||||
)
|
||||
else:
|
||||
failed_count = total_count - successful_count
|
||||
self.statusBar().showMessage(
|
||||
f"⚠ {successful_count}/{total_count} erfolgreich, {failed_count} fehlgeschlagen",
|
||||
5000
|
||||
)
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Abgeschlossen mit Fehlern",
|
||||
f"{successful_count} von {total_count} Transformationen erfolgreich\n"
|
||||
f"{failed_count} fehlgeschlagen"
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
# Stoppe Transformations-Thread falls noch aktiv
|
||||
if hasattr(self, 'transformation_thread') and self.transformation_thread and self.transformation_thread.isRunning():
|
||||
self.transformation_thread.quit()
|
||||
self.transformation_thread.wait()
|
||||
|
||||
# PDF-Dokumente schließen ist bei QtPdf automatisch durch Garbage Collection
|
||||
super().closeEvent(event)
|
||||
|
||||
@@ -44,6 +44,11 @@ dependencies = [
|
||||
{ name = "pyside6" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "polars", extras = ["connectorx", "pyarrow"], specifier = ">=1.31.0" },
|
||||
@@ -54,7 +59,7 @@ requires-dist = [
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = []
|
||||
dev = [{ name = "ruff", specifier = ">=0.14.8" }]
|
||||
|
||||
[[package]]
|
||||
name = "polars"
|
||||
@@ -269,6 +274,32 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/85/e8e751d8791564dd333d5d9a4eab0a7a115f7e349595417fd50ecae3395c/ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3", size = 115190, upload-time = "2024-10-20T10:13:10.66Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.14.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ed/d9/f7a0c4b3a2bf2556cd5d99b05372c29980249ef71e8e32669ba77428c82c/ruff-0.14.8.tar.gz", hash = "sha256:774ed0dd87d6ce925e3b8496feb3a00ac564bea52b9feb551ecd17e0a23d1eed", size = 5765385, upload-time = "2025-12-04T15:06:17.669Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/b8/9537b52010134b1d2b72870cc3f92d5fb759394094741b09ceccae183fbe/ruff-0.14.8-py3-none-linux_armv6l.whl", hash = "sha256:ec071e9c82eca417f6111fd39f7043acb53cd3fde9b1f95bbed745962e345afb", size = 13441540, upload-time = "2025-12-04T15:06:14.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/00/99031684efb025829713682012b6dd37279b1f695ed1b01725f85fd94b38/ruff-0.14.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8cdb162a7159f4ca36ce980a18c43d8f036966e7f73f866ac8f493b75e0c27e9", size = 13669384, upload-time = "2025-12-04T15:06:51.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/64/3eb5949169fc19c50c04f28ece2c189d3b6edd57e5b533649dae6ca484fe/ruff-0.14.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e2fcbefe91f9fad0916850edf0854530c15bd1926b6b779de47e9ab619ea38f", size = 12806917, upload-time = "2025-12-04T15:06:08.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/08/5250babb0b1b11910f470370ec0cbc67470231f7cdc033cee57d4976f941/ruff-0.14.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9d70721066a296f45786ec31916dc287b44040f553da21564de0ab4d45a869b", size = 13256112, upload-time = "2025-12-04T15:06:23.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/4c/6c588e97a8e8c2d4b522c31a579e1df2b4d003eddfbe23d1f262b1a431ff/ruff-0.14.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c87e09b3cd9d126fc67a9ecd3b5b1d3ded2b9c7fce3f16e315346b9d05cfb52", size = 13227559, upload-time = "2025-12-04T15:06:33.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/ce/5f78cea13eda8eceac71b5f6fa6e9223df9b87bb2c1891c166d1f0dce9f1/ruff-0.14.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d62cb310c4fbcb9ee4ac023fe17f984ae1e12b8a4a02e3d21489f9a2a5f730c", size = 13896379, upload-time = "2025-12-04T15:06:02.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/79/13de4517c4dadce9218a20035b21212a4c180e009507731f0d3b3f5df85a/ruff-0.14.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1af35c2d62633d4da0521178e8a2641c636d2a7153da0bac1b30cfd4ccd91344", size = 15372786, upload-time = "2025-12-04T15:06:29.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/06/33df72b3bb42be8a1c3815fd4fae83fa2945fc725a25d87ba3e42d1cc108/ruff-0.14.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25add4575ffecc53d60eed3f24b1e934493631b48ebbc6ebaf9d8517924aca4b", size = 14990029, upload-time = "2025-12-04T15:06:36.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/61/0f34927bd90925880394de0e081ce1afab66d7b3525336f5771dcf0cb46c/ruff-0.14.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c943d847b7f02f7db4201a0600ea7d244d8a404fbb639b439e987edcf2baf9a", size = 14407037, upload-time = "2025-12-04T15:06:39.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/bc/058fe0aefc0fbf0d19614cb6d1a3e2c048f7dc77ca64957f33b12cfdc5ef/ruff-0.14.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb6e8bf7b4f627548daa1b69283dac5a296bfe9ce856703b03130732e20ddfe2", size = 14102390, upload-time = "2025-12-04T15:06:46.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/a4/e4f77b02b804546f4c17e8b37a524c27012dd6ff05855d2243b49a7d3cb9/ruff-0.14.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7aaf2974f378e6b01d1e257c6948207aec6a9b5ba53fab23d0182efb887a0e4a", size = 14230793, upload-time = "2025-12-04T15:06:20.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/52/bb8c02373f79552e8d087cedaffad76b8892033d2876c2498a2582f09dcf/ruff-0.14.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e5758ca513c43ad8a4ef13f0f081f80f08008f410790f3611a21a92421ab045b", size = 13160039, upload-time = "2025-12-04T15:06:49.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/ad/b69d6962e477842e25c0b11622548df746290cc6d76f9e0f4ed7456c2c31/ruff-0.14.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f74f7ba163b6e85a8d81a590363bf71618847e5078d90827749bfda1d88c9cdf", size = 13205158, upload-time = "2025-12-04T15:06:54.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/63/54f23da1315c0b3dfc1bc03fbc34e10378918a20c0b0f086418734e57e74/ruff-0.14.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:eed28f6fafcc9591994c42254f5a5c5ca40e69a30721d2ab18bb0bb3baac3ab6", size = 13469550, upload-time = "2025-12-04T15:05:59.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/7d/a4d7b1961e4903bc37fffb7ddcfaa7beb250f67d97cfd1ee1d5cddb1ec90/ruff-0.14.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:21d48fa744c9d1cb8d71eb0a740c4dd02751a5de9db9a730a8ef75ca34cf138e", size = 14211332, upload-time = "2025-12-04T15:06:06.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/93/2a5063341fa17054e5c86582136e9895db773e3c2ffb770dde50a09f35f0/ruff-0.14.8-py3-none-win32.whl", hash = "sha256:15f04cb45c051159baebb0f0037f404f1dc2f15a927418f29730f411a79bc4e7", size = 13151890, upload-time = "2025-12-04T15:06:11.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/1c/65c61a0859c0add13a3e1cbb6024b42de587456a43006ca2d4fd3d1618fe/ruff-0.14.8-py3-none-win_amd64.whl", hash = "sha256:9eeb0b24242b5bbff3011409a739929f497f3fb5fe3b5698aba5e77e8c833097", size = 14537826, upload-time = "2025-12-04T15:06:26.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/63/8b41cea3afd7f58eb64ac9251668ee0073789a3bc9ac6f816c8c6fef986d/ruff-0.14.8-py3-none-win_arm64.whl", hash = "sha256:965a582c93c63fe715fd3e3f8aa37c4b776777203d8e1d8aa3cc0c14424a4b99", size = 13634522, upload-time = "2025-12-04T15:06:43.212Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shiboken6"
|
||||
version = "6.9.2"
|
||||
|
||||
Reference in New Issue
Block a user