Files
xsl-validator/src/xsl_dependencies.py
T

242 lines
8.3 KiB
Python
Raw Normal View History

"""
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 time
from lxml import etree
from pathlib import Path
logger = logging.getLogger(__name__)
_XSL_NAMESPACE = "http://www.w3.org/1999/XSL/Transform"
def _parse_import_include_hrefs(content: str) -> list[str]:
"""Parst XSL-Dateiinhalt und gibt alle href-Werte von xsl:import/xsl:include zurück."""
try:
root = etree.fromstring(content.encode("utf-8"))
except etree.XMLSyntaxError:
return []
hrefs = []
for tag in ("import", "include"):
for elem in root.iter(f"{{{_XSL_NAMESPACE}}}{tag}"):
href = elem.get("href")
if href:
hrefs.append(href)
return hrefs
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 href in _parse_import_include_hrefs(content):
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 get_reverse_dependencies(self, xsl_file: Path, xsl_root_dir: Path) -> set[Path]:
"""
Gibt alle Dateien zurück, die die gegebene XSL-Datei direkt oder transitiv importieren/inkludieren.
Args:
xsl_file: Absoluter Pfad zur XSL-Datei
xsl_root_dir: Wurzelverzeichnis der XSL-Dateien (für den Scan)
Returns:
set[Path]: Menge aller Dateien, die diese Datei importieren
"""
xsl_file = xsl_file.resolve()
result: set[Path] = set()
# Scanne alle XSL-Dateien im Verzeichnis
if not xsl_root_dir.exists():
return result
for candidate in xsl_root_dir.rglob("*.xsl"):
candidate = candidate.resolve()
if candidate == xsl_file:
continue
deps = self.get_dependencies(candidate)
if xsl_file in deps:
result.add(candidate)
return result
def build_full_graph(self, xsl_root_dir: Path) -> dict[Path, set[Path]]:
"""
Baut den vollständigen Abhängigkeitsgraph für alle XSL-Dateien in einem Verzeichnis auf.
Args:
xsl_root_dir: Wurzelverzeichnis der XSL-Dateien
Returns:
dict[Path, set[Path]]: Mapping von XSL-Datei zu ihren Abhängigkeiten
"""
graph: dict[Path, set[Path]] = {}
if not xsl_root_dir.exists():
return graph
start = time.perf_counter()
for xsl_file in sorted(xsl_root_dir.rglob("*.xsl")):
xsl_file = xsl_file.resolve()
deps = self.get_dependencies(xsl_file)
graph[xsl_file] = deps
elapsed = time.perf_counter() - start
logger.info(f"Vollständiger XSL-Graph aufgebaut: {len(graph)} Dateien in {elapsed:.3f}s")
return graph
def _get_direct_dependencies(self, xsl_file: Path) -> set[Path]:
"""
Gibt nur die direkten (nicht-transitiven) import/include-Abhängigkeiten zurück.
Args:
xsl_file: Absoluter Pfad zur XSL-Datei
Returns:
set[Path]: Menge der direkt importierten/inkludierten XSL-Dateien
"""
xsl_file = xsl_file.resolve()
result: set[Path] = set()
if not xsl_file.exists():
return result
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
for href in _parse_import_include_hrefs(content):
referenced_path = (xsl_file.parent / href).resolve()
if referenced_path.exists():
result.add(referenced_path)
return result
def build_direct_graph(self, xsl_root_dir: Path) -> dict[Path, set[Path]]:
"""
Baut einen Graph mit nur direkten (nicht-transitiven) Abhängigkeiten auf.
Args:
xsl_root_dir: Wurzelverzeichnis der XSL-Dateien
Returns:
dict[Path, set[Path]]: Mapping von XSL-Datei zu ihren direkten Abhängigkeiten
"""
graph: dict[Path, set[Path]] = {}
if not xsl_root_dir.exists():
return graph
start = time.perf_counter()
for xsl_file in sorted(xsl_root_dir.rglob("*.xsl")):
xsl_file = xsl_file.resolve()
graph[xsl_file] = self._get_direct_dependencies(xsl_file)
elapsed = time.perf_counter() - start
logger.info(f"Direkter XSL-Graph aufgebaut: {len(graph)} Dateien in {elapsed:.3f}s")
return graph
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)")