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_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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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