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:
2026-03-14 19:37:05 +01:00
parent 140905af77
commit 71fa48a514
4 changed files with 248 additions and 0 deletions
+11
View File
@@ -17,6 +17,7 @@ if TYPE_CHECKING:
from saxon_pool import SaxonWorkerPool
from saxon_pool_s9api import SaxonWorkerPoolS9Api
from fop_pool import FopWorkerPool
from xsl_dependencies import XslDependencyGraph
logger = logging.getLogger(__name__)
@@ -81,6 +82,7 @@ class TransformationJob:
diff_pdf_params: list[str],
xsl_id: tuple | None = None,
fop_config_dir: Path | None = None,
dependency_graph: Optional["XslDependencyGraph"] = None,
):
"""
Initialisiert einen Transformations-Job.
@@ -97,6 +99,7 @@ class TransformationJob:
diff_pdf_params: Standard-Parameter für diff-pdf
xsl_id: ID der XSL-Datei (als Tuple)
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.xml_file = xml_file # Relativ
@@ -111,6 +114,7 @@ class TransformationJob:
self.fop_config_dir = fop_config_dir
self.diff_pdf_path = diff_pdf_path
self.diff_pdf_params = diff_pdf_params
self.dependency_graph = dependency_graph
# Ausgabe-Verzeichnisse im Projektordner
self.new_dir = project_dir / "new"
@@ -176,6 +180,13 @@ class TransformationJob:
logger.debug(f"XSL-Datei ist neuer: {self.xsl_file}")
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}")
return True
+7
View File
@@ -15,6 +15,7 @@ from PySide6.QtWidgets import QMessageBox, QProgressBar, QTreeWidgetItem
from conf import app_settings, TreeNode, XslFile, XmlFile
from transform import TransformationJob
from ui.threads import TransformationThread
from xsl_dependencies import XslDependencyGraph
logger = logging.getLogger(__name__)
@@ -31,6 +32,7 @@ class TransformationMixin:
- self.xml_item_map: Mapping von xml_path|xsl_id zu TreeWidgetItems
- self.last_saxon_metrics: Gecachte Saxon-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:
- 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}")
# 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
job = TransformationJob(
project_dir=self.project.project_dir,
@@ -507,6 +513,7 @@ class TransformationMixin:
diff_pdf_params=diff_pdf.default_params,
xsl_id=xsl_file_obj.id,
fop_config_dir=self.project.fop_config_dir,
dependency_graph=self.xsl_dependency_graph,
)
return job
+109
View File
@@ -389,6 +389,13 @@ class TreeManagerMixin:
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.setIcon(QIcon(QIcon.fromTheme(QIcon.ThemeIcon.DocumentProperties)))
action_edit.triggered.connect(lambda: self._edit_xsl_file(item))
@@ -567,6 +574,9 @@ class TreeManagerMixin:
item.setDisabled(True)
item.setToolTip(0, f"XSL-Datei nicht gefunden: {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
if node.xmls:
@@ -1189,6 +1199,105 @@ class TreeManagerMixin:
logger.error(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
def _edit_xml_file(self, item):
"""Bearbeitet eine XML-Datei."""
+121
View File
@@ -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)")