Feat: XSL-Abhängigkeitsgraph für import/include-Erkennung in Transformations-Pipeline
is_up_to_date() prüft nun auch transitiv importierte/inkludierte XSL-Dateien. Abhängigkeiten werden per Tooltip und Kontextmenü-Aktion im TreeWidget angezeigt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ if TYPE_CHECKING:
|
|||||||
from saxon_pool import SaxonWorkerPool
|
from saxon_pool import SaxonWorkerPool
|
||||||
from saxon_pool_s9api import SaxonWorkerPoolS9Api
|
from saxon_pool_s9api import SaxonWorkerPoolS9Api
|
||||||
from fop_pool import FopWorkerPool
|
from fop_pool import FopWorkerPool
|
||||||
|
from xsl_dependencies import XslDependencyGraph
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ class TransformationJob:
|
|||||||
diff_pdf_params: list[str],
|
diff_pdf_params: list[str],
|
||||||
xsl_id: tuple | None = None,
|
xsl_id: tuple | None = None,
|
||||||
fop_config_dir: Path | None = None,
|
fop_config_dir: Path | None = None,
|
||||||
|
dependency_graph: Optional["XslDependencyGraph"] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialisiert einen Transformations-Job.
|
Initialisiert einen Transformations-Job.
|
||||||
@@ -97,6 +99,7 @@ class TransformationJob:
|
|||||||
diff_pdf_params: Standard-Parameter für diff-pdf
|
diff_pdf_params: Standard-Parameter für diff-pdf
|
||||||
xsl_id: ID der XSL-Datei (als Tuple)
|
xsl_id: ID der XSL-Datei (als Tuple)
|
||||||
fop_config_dir: Optionaler Pfad zum FOP-Config-Verzeichnis (überschreibt Standardpfad)
|
fop_config_dir: Optionaler Pfad zum FOP-Config-Verzeichnis (überschreibt Standardpfad)
|
||||||
|
dependency_graph: Optionaler XSL-Abhängigkeitsgraph für Import/Include-Prüfung
|
||||||
"""
|
"""
|
||||||
self.project_dir = project_dir
|
self.project_dir = project_dir
|
||||||
self.xml_file = xml_file # Relativ
|
self.xml_file = xml_file # Relativ
|
||||||
@@ -111,6 +114,7 @@ class TransformationJob:
|
|||||||
self.fop_config_dir = fop_config_dir
|
self.fop_config_dir = fop_config_dir
|
||||||
self.diff_pdf_path = diff_pdf_path
|
self.diff_pdf_path = diff_pdf_path
|
||||||
self.diff_pdf_params = diff_pdf_params
|
self.diff_pdf_params = diff_pdf_params
|
||||||
|
self.dependency_graph = dependency_graph
|
||||||
|
|
||||||
# Ausgabe-Verzeichnisse im Projektordner
|
# Ausgabe-Verzeichnisse im Projektordner
|
||||||
self.new_dir = project_dir / "new"
|
self.new_dir = project_dir / "new"
|
||||||
@@ -176,6 +180,13 @@ class TransformationJob:
|
|||||||
logger.debug(f"XSL-Datei ist neuer: {self.xsl_file}")
|
logger.debug(f"XSL-Datei ist neuer: {self.xsl_file}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Prüfe importierte/inkludierte XSL-Dateien (transitiv)
|
||||||
|
if self.dependency_graph and self.xsl_file.exists():
|
||||||
|
for dep_xsl in self.dependency_graph.get_dependencies(self.xsl_file):
|
||||||
|
if dep_xsl.exists() and dep_xsl.stat().st_mtime > output_mtime:
|
||||||
|
logger.debug(f"Importierte XSL-Datei ist neuer: {dep_xsl}")
|
||||||
|
return False
|
||||||
|
|
||||||
logger.debug(f"Transformation ist aktuell: {self.new_pdf}")
|
logger.debug(f"Transformation ist aktuell: {self.new_pdf}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from PySide6.QtWidgets import QMessageBox, QProgressBar, QTreeWidgetItem
|
|||||||
from conf import app_settings, TreeNode, XslFile, XmlFile
|
from conf import app_settings, TreeNode, XslFile, XmlFile
|
||||||
from transform import TransformationJob
|
from transform import TransformationJob
|
||||||
from ui.threads import TransformationThread
|
from ui.threads import TransformationThread
|
||||||
|
from xsl_dependencies import XslDependencyGraph
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ class TransformationMixin:
|
|||||||
- self.xml_item_map: Mapping von xml_path|xsl_id zu TreeWidgetItems
|
- self.xml_item_map: Mapping von xml_path|xsl_id zu TreeWidgetItems
|
||||||
- self.last_saxon_metrics: Gecachte Saxon-Worker-Pool-Metriken
|
- self.last_saxon_metrics: Gecachte Saxon-Worker-Pool-Metriken
|
||||||
- self.last_fop_metrics: Gecachte FOP-Worker-Pool-Metriken
|
- self.last_fop_metrics: Gecachte FOP-Worker-Pool-Metriken
|
||||||
|
- self.xsl_dependency_graph: XslDependencyGraph für Import/Include-Erkennung
|
||||||
|
|
||||||
Erwartet folgende Methoden von anderen Mixins:
|
Erwartet folgende Methoden von anderen Mixins:
|
||||||
- self._initialize_saxon_worker_pool(): Von WorkerPoolMixin
|
- self._initialize_saxon_worker_pool(): Von WorkerPoolMixin
|
||||||
@@ -494,6 +496,10 @@ class TransformationMixin:
|
|||||||
|
|
||||||
logger.info(f"Finale XSLT-Parameter für {xml_file_obj.xml} mit {xsl_file_obj.bez}: {xslt_params}")
|
logger.info(f"Finale XSLT-Parameter für {xml_file_obj.xml} mit {xsl_file_obj.bez}: {xslt_params}")
|
||||||
|
|
||||||
|
# Initialisiere XSL-Abhängigkeitsgraph (lazy, einmalig pro Mixin-Instanz)
|
||||||
|
if not hasattr(self, "xsl_dependency_graph") or self.xsl_dependency_graph is None:
|
||||||
|
self.xsl_dependency_graph = XslDependencyGraph()
|
||||||
|
|
||||||
# Erstelle TransformationJob
|
# Erstelle TransformationJob
|
||||||
job = TransformationJob(
|
job = TransformationJob(
|
||||||
project_dir=self.project.project_dir,
|
project_dir=self.project.project_dir,
|
||||||
@@ -507,6 +513,7 @@ class TransformationMixin:
|
|||||||
diff_pdf_params=diff_pdf.default_params,
|
diff_pdf_params=diff_pdf.default_params,
|
||||||
xsl_id=xsl_file_obj.id,
|
xsl_id=xsl_file_obj.id,
|
||||||
fop_config_dir=self.project.fop_config_dir,
|
fop_config_dir=self.project.fop_config_dir,
|
||||||
|
dependency_graph=self.xsl_dependency_graph,
|
||||||
)
|
)
|
||||||
|
|
||||||
return job
|
return job
|
||||||
|
|||||||
@@ -389,6 +389,13 @@ class TreeManagerMixin:
|
|||||||
|
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
|
|
||||||
|
action_deps = QAction("Abhängigkeiten anzeigen", self)
|
||||||
|
action_deps.setIcon(QIcon(QIcon.fromTheme("view-list-tree")))
|
||||||
|
action_deps.triggered.connect(lambda: self._show_xsl_dependencies(item))
|
||||||
|
menu.addAction(action_deps)
|
||||||
|
|
||||||
|
menu.addSeparator()
|
||||||
|
|
||||||
action_edit = QAction("Bearbeiten", self)
|
action_edit = QAction("Bearbeiten", self)
|
||||||
action_edit.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.DocumentProperties)))
|
action_edit.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.DocumentProperties)))
|
||||||
action_edit.triggered.connect(lambda: self._edit_xsl_file(item))
|
action_edit.triggered.connect(lambda: self._edit_xsl_file(item))
|
||||||
@@ -567,6 +574,9 @@ class TreeManagerMixin:
|
|||||||
item.setDisabled(True)
|
item.setDisabled(True)
|
||||||
item.setToolTip(0, f"XSL-Datei nicht gefunden: {xsl_file_abs}")
|
item.setToolTip(0, f"XSL-Datei nicht gefunden: {xsl_file_abs}")
|
||||||
logger.warning(f"XSL-Datei nicht vorhanden: {xsl_file_abs}")
|
logger.warning(f"XSL-Datei nicht vorhanden: {xsl_file_abs}")
|
||||||
|
else:
|
||||||
|
# Tooltip mit Abhängigkeiten (import/include) setzen
|
||||||
|
self._set_xsl_dependency_tooltip(item, xsl_file_abs)
|
||||||
|
|
||||||
# Lade XML-Dateien als Knoten
|
# Lade XML-Dateien als Knoten
|
||||||
if node.xmls:
|
if node.xmls:
|
||||||
@@ -1189,6 +1199,105 @@ class TreeManagerMixin:
|
|||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
QMessageBox.critical(self, "Fehler", error_msg)
|
QMessageBox.critical(self, "Fehler", error_msg)
|
||||||
|
|
||||||
|
def _get_xsl_abs_path(self, xsl_file_obj: XslFile) -> Path | None:
|
||||||
|
"""
|
||||||
|
Ermittelt den absoluten Pfad einer XSL-Datei anhand der Projekt-Konfiguration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
xsl_file_obj: Das XslFile-Objekt
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Absoluter Pfad oder None wenn nicht ermittelbar
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "project") or not self.project:
|
||||||
|
return None
|
||||||
|
|
||||||
|
from conf import app_settings
|
||||||
|
|
||||||
|
xsl_dir = next((xd for xd in app_settings.xsl_dirs if xd.id == self.project.xsl_dir_id), None)
|
||||||
|
if not xsl_dir:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return xsl_dir.path_to_root_dir / xsl_file_obj.xsl_file
|
||||||
|
|
||||||
|
def _ensure_xsl_dependency_graph(self):
|
||||||
|
"""Stellt sicher, dass der XSL-Abhängigkeitsgraph initialisiert ist."""
|
||||||
|
if not hasattr(self, "xsl_dependency_graph") or self.xsl_dependency_graph is None:
|
||||||
|
from xsl_dependencies import XslDependencyGraph
|
||||||
|
|
||||||
|
self.xsl_dependency_graph = XslDependencyGraph()
|
||||||
|
|
||||||
|
def _set_xsl_dependency_tooltip(self, item: QTreeWidgetItem, xsl_file_abs: Path):
|
||||||
|
"""
|
||||||
|
Setzt einen Tooltip mit den Abhängigkeiten (import/include) einer XSL-Datei.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item: Das TreeWidgetItem der XSL-Datei
|
||||||
|
xsl_file_abs: Absoluter Pfad zur XSL-Datei
|
||||||
|
"""
|
||||||
|
self._ensure_xsl_dependency_graph()
|
||||||
|
|
||||||
|
deps = self.xsl_dependency_graph.get_dependencies(xsl_file_abs)
|
||||||
|
if not deps:
|
||||||
|
item.setToolTip(0, f"{xsl_file_abs.name}\nKeine Abhängigkeiten (import/include)")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Sortierte Liste der Abhängigkeiten (nur Dateinamen)
|
||||||
|
dep_names = sorted(dep.name for dep in deps)
|
||||||
|
dep_list = "\n".join(f" - {name}" for name in dep_names)
|
||||||
|
tooltip = f"{xsl_file_abs.name}\n{len(deps)} Abhängigkeit(en):\n{dep_list}"
|
||||||
|
item.setToolTip(0, tooltip)
|
||||||
|
|
||||||
|
def _show_xsl_dependencies(self, item: QTreeWidgetItem):
|
||||||
|
"""
|
||||||
|
Zeigt einen Dialog mit den Abhängigkeiten einer XSL-Datei.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item: Das TreeWidgetItem der XSL-Datei
|
||||||
|
"""
|
||||||
|
xsl_file_obj = item.data(0, Qt.ItemDataRole.UserRole)
|
||||||
|
if not isinstance(xsl_file_obj, XslFile):
|
||||||
|
return
|
||||||
|
|
||||||
|
xsl_file_abs = self._get_xsl_abs_path(xsl_file_obj)
|
||||||
|
if not xsl_file_abs or not xsl_file_abs.exists():
|
||||||
|
QMessageBox.warning(self, "Fehler", f"XSL-Datei nicht gefunden: {xsl_file_abs}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._ensure_xsl_dependency_graph()
|
||||||
|
deps = self.xsl_dependency_graph.get_dependencies(xsl_file_abs)
|
||||||
|
|
||||||
|
if not deps:
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
f"Abhängigkeiten: {xsl_file_obj.bez}",
|
||||||
|
f"Die XSL-Datei '{xsl_file_abs.name}' hat keine Abhängigkeiten (import/include).",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Sortierte Liste mit relativen Pfaden (relativ zum XSL-Verzeichnis)
|
||||||
|
xsl_root = xsl_file_abs.parent
|
||||||
|
from conf import app_settings
|
||||||
|
|
||||||
|
xsl_dir = next((xd for xd in app_settings.xsl_dirs if xd.id == self.project.xsl_dir_id), None)
|
||||||
|
if xsl_dir:
|
||||||
|
xsl_root = xsl_dir.path_to_root_dir
|
||||||
|
|
||||||
|
dep_lines = []
|
||||||
|
for dep in sorted(deps, key=lambda p: p.name):
|
||||||
|
try:
|
||||||
|
rel_path = dep.relative_to(xsl_root)
|
||||||
|
except ValueError:
|
||||||
|
rel_path = dep.name
|
||||||
|
dep_lines.append(f" - {rel_path}")
|
||||||
|
|
||||||
|
dep_text = "\n".join(dep_lines)
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
f"Abhängigkeiten: {xsl_file_obj.bez}",
|
||||||
|
f"Die XSL-Datei '{xsl_file_abs.name}' importiert/inkludiert {len(deps)} Datei(en):\n\n{dep_text}",
|
||||||
|
)
|
||||||
|
|
||||||
# Kontextmenü-Aktionen für XmlFile
|
# Kontextmenü-Aktionen für XmlFile
|
||||||
def _edit_xml_file(self, item):
|
def _edit_xml_file(self, item):
|
||||||
"""Bearbeitet eine XML-Datei."""
|
"""Bearbeitet eine XML-Datei."""
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
XSL-Abhängigkeitsgraph für die Erkennung transitiver Imports/Includes.
|
||||||
|
|
||||||
|
Dieses Modul parst XSL-Dateien und baut einen Abhängigkeitsgraph auf,
|
||||||
|
um alle direkt und transitiv importierten/inkludierten Dateien zu ermitteln.
|
||||||
|
Der Graph wird im Speicher gehalten und bei Änderungen (mtime) automatisch invalidiert.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Regex für xsl:import und xsl:include href-Attribute
|
||||||
|
_IMPORT_INCLUDE_PATTERN = re.compile(r'<xsl:(?:import|include)\s+href=["\']([^"\']+)["\']', re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
class XslDependencyGraph:
|
||||||
|
"""
|
||||||
|
Verwaltet einen Cache von XSL-Abhängigkeiten (import/include).
|
||||||
|
|
||||||
|
Für jede XSL-Datei wird die Menge aller transitiv referenzierten
|
||||||
|
XSL-Dateien gespeichert. Der Cache wird über die mtime der Dateien
|
||||||
|
automatisch invalidiert.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Cache: xsl_path -> (mtime_bei_parse, set[Path] aller Abhängigkeiten)
|
||||||
|
self._cache: dict[Path, tuple[float, set[Path]]] = {}
|
||||||
|
|
||||||
|
def get_dependencies(self, xsl_file: Path) -> set[Path]:
|
||||||
|
"""
|
||||||
|
Gibt alle transitiven Abhängigkeiten einer XSL-Datei zurück.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
xsl_file: Absoluter Pfad zur XSL-Datei
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
set[Path]: Menge aller transitiv importierten/inkludierten XSL-Dateien
|
||||||
|
"""
|
||||||
|
xsl_file = xsl_file.resolve()
|
||||||
|
|
||||||
|
if not xsl_file.exists():
|
||||||
|
return set()
|
||||||
|
|
||||||
|
current_mtime = xsl_file.stat().st_mtime
|
||||||
|
|
||||||
|
# Cache-Hit prüfen
|
||||||
|
if xsl_file in self._cache:
|
||||||
|
cached_mtime, cached_deps = self._cache[xsl_file]
|
||||||
|
if cached_mtime == current_mtime:
|
||||||
|
return cached_deps
|
||||||
|
|
||||||
|
# Neu parsen
|
||||||
|
deps = self._resolve_recursive(xsl_file, set())
|
||||||
|
self._cache[xsl_file] = (current_mtime, deps)
|
||||||
|
logger.debug(f"XSL-Abhängigkeiten aufgelöst für {xsl_file.name}: {len(deps)} Abhängigkeit(en)")
|
||||||
|
return deps
|
||||||
|
|
||||||
|
def _resolve_recursive(self, xsl_file: Path, visited: set[Path]) -> set[Path]:
|
||||||
|
"""
|
||||||
|
Löst rekursiv alle import/include-Referenzen auf.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
xsl_file: Absoluter Pfad zur XSL-Datei
|
||||||
|
visited: Bereits besuchte Dateien (Zykluserkennung)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
set[Path]: Alle transitiven Abhängigkeiten (ohne die Datei selbst)
|
||||||
|
"""
|
||||||
|
xsl_file = xsl_file.resolve()
|
||||||
|
|
||||||
|
if xsl_file in visited or not xsl_file.exists():
|
||||||
|
return set()
|
||||||
|
|
||||||
|
visited.add(xsl_file)
|
||||||
|
result: set[Path] = set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = xsl_file.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Konnte XSL-Datei nicht lesen: {xsl_file} ({e})")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Finde alle import/include-Referenzen
|
||||||
|
for match in _IMPORT_INCLUDE_PATTERN.finditer(content):
|
||||||
|
href = match.group(1)
|
||||||
|
referenced_path = (xsl_file.parent / href).resolve()
|
||||||
|
|
||||||
|
if referenced_path.exists() and referenced_path not in visited:
|
||||||
|
result.add(referenced_path)
|
||||||
|
# Rekursiv Abhängigkeiten der referenzierten Datei auflösen
|
||||||
|
result.update(self._resolve_recursive(referenced_path, visited))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def invalidate(self, xsl_file: Path | None = None):
|
||||||
|
"""
|
||||||
|
Invalidiert den Cache für eine bestimmte Datei oder den gesamten Cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
xsl_file: Pfad zur XSL-Datei oder None für vollständige Invalidierung
|
||||||
|
"""
|
||||||
|
if xsl_file is None:
|
||||||
|
self._cache.clear()
|
||||||
|
logger.debug("XSL-Abhängigkeitscache vollständig invalidiert")
|
||||||
|
else:
|
||||||
|
xsl_file = xsl_file.resolve()
|
||||||
|
# Entferne nicht nur den direkten Eintrag, sondern auch alle Einträge,
|
||||||
|
# die diese Datei als Abhängigkeit haben
|
||||||
|
keys_to_remove = [xsl_file]
|
||||||
|
for cached_path, (_, deps) in self._cache.items():
|
||||||
|
if xsl_file in deps:
|
||||||
|
keys_to_remove.append(cached_path)
|
||||||
|
|
||||||
|
for key in keys_to_remove:
|
||||||
|
self._cache.pop(key, None)
|
||||||
|
|
||||||
|
if keys_to_remove:
|
||||||
|
logger.debug(f"XSL-Abhängigkeitscache invalidiert für {len(keys_to_remove)} Einträg(e)")
|
||||||
Reference in New Issue
Block a user