From 71fa48a514fe5682fa2749383c375892b4427f50 Mon Sep 17 00:00:00 2001 From: Vitali Graf Date: Sat, 14 Mar 2026 19:37:05 +0100 Subject: [PATCH] =?UTF-8?q?Feat:=20XSL-Abh=C3=A4ngigkeitsgraph=20f=C3=BCr?= =?UTF-8?q?=20import/include-Erkennung=20in=20Transformations-Pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/transform.py | 11 +++ src/ui/mixins/transformation.py | 7 ++ src/ui/mixins/tree_manager.py | 109 ++++++++++++++++++++++++++++ src/xsl_dependencies.py | 121 ++++++++++++++++++++++++++++++++ 4 files changed, 248 insertions(+) create mode 100644 src/xsl_dependencies.py diff --git a/src/transform.py b/src/transform.py index f3d8d8a..2b372b1 100644 --- a/src/transform.py +++ b/src/transform.py @@ -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 diff --git a/src/ui/mixins/transformation.py b/src/ui/mixins/transformation.py index 8237448..1af3271 100644 --- a/src/ui/mixins/transformation.py +++ b/src/ui/mixins/transformation.py @@ -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 diff --git a/src/ui/mixins/tree_manager.py b/src/ui/mixins/tree_manager.py index b5f4838..8150567 100644 --- a/src/ui/mixins/tree_manager.py +++ b/src/ui/mixins/tree_manager.py @@ -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.""" diff --git a/src/xsl_dependencies.py b/src/xsl_dependencies.py new file mode 100644 index 0000000..2a65d90 --- /dev/null +++ b/src/xsl_dependencies.py @@ -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' (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)")