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
+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)")