Files
xsl-validator/src/license_parser.py
T
info bf2db92040 Version 1.6.6: Versionsanzeige im Info-Dialog bei PyInstaller-Bundle gefixt
Im PyInstaller-Bundle fehlten .dist-info-Metadaten, wodurch importlib.metadata
keine Paketversionen liefern konnte. Lösung: DocuMentor.spec erzeugt beim Build
einen Versions-Snapshot (versions.json) der ins Bundle eingebettet wird.
license_parser.py liest diesen Snapshot im Bundle-Modus statt importlib.metadata.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:54:27 +02:00

219 lines
7.4 KiB
Python

"""
Parser für THIRD_PARTY_LICENSES.txt.
Extrahiert strukturierte Lizenzinformationen und ergänzt sie
mit den tatsächlich installierten Paketversionen via importlib.metadata
oder (im PyInstaller-Bundle) aus der mitgebündelten versions.json.
"""
import json
import logging
import re
import sys
from dataclasses import dataclass, field
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
logger = logging.getLogger(__name__)
# Pfad zur Lizenzdatei: im PyInstaller-Bundle aus sys._MEIPASS,
# im Entwicklungsmodus relativ zum Projektroot
if hasattr(sys, "_MEIPASS"):
LICENSE_FILE = Path(sys._MEIPASS) / "THIRD_PARTY_LICENSES.txt" # type: ignore[attr-defined]
else:
LICENSE_FILE = Path(__file__).parent.parent / "THIRD_PARTY_LICENSES.txt"
# Mapping von Anzeigenamen zu PyPI-Paketnamen für Sonderfälle
_PACKAGE_NAME_MAP: dict[str, str] = {
"PySide6": "PySide6",
"Pydantic": "pydantic",
"Pydantic-Settings": "pydantic-settings",
"Pydantic-YAML": "pydantic-yaml",
"Polars": "polars",
"ConnectorX (via Polars)": "connectorx",
"PyArrow (via Polars)": "pyarrow",
"psutil": "psutil",
"lxml": "lxml",
"Ruff (Development)": "ruff",
"PyInstaller (Development)": "pyinstaller",
"Pillow (Development)": "pillow",
}
# Regex für den Beginn eines nummerierten Eintrags (z.B. "1. PySide6" oder "10. PyInstaller (Development)")
_ENTRY_PATTERN = re.compile(r"^\d+\.\s+(.+)$")
# Bekannte Feldschlüssel in der Lizenzdatei
_KNOWN_KEYS = {"Version", "Lizenz", "Webseite", "GitHub", "Beschreibung", "Copyright", "Datei", "Hinweis"}
@dataclass
class LicenseEntry:
"""Ein einzelner Eintrag aus THIRD_PARTY_LICENSES.txt."""
name: str
license: str = ""
installed_version: str = ""
website: str = ""
description: str = ""
copyright: str = ""
category: str = ""
@dataclass
class ParsedLicenses:
"""Ergebnis des Parsens der Lizenzdatei."""
entries: list[LicenseEntry] = field(default_factory=list)
def _normalize_package_name(display_name: str) -> str:
"""Normalisiert einen Anzeigenamen zu einem PyPI-Paketnamen."""
if display_name in _PACKAGE_NAME_MAP:
return _PACKAGE_NAME_MAP[display_name]
# Fallback: lowercase, Leerzeichen/Klammern entfernen
return display_name.lower().split("(")[0].strip()
_bundled_versions: dict[str, str] | None = None
def _load_bundled_versions() -> dict[str, str]:
"""Lädt den Versions-Snapshot aus der mitgebündelten versions.json (nur im PyInstaller-Bundle)."""
global _bundled_versions
if _bundled_versions is None:
if hasattr(sys, "_MEIPASS"):
versions_file = Path(sys._MEIPASS) / "versions.json" # type: ignore[attr-defined]
if versions_file.exists():
_bundled_versions = json.loads(versions_file.read_text(encoding="utf-8"))
else:
_bundled_versions = {}
else:
_bundled_versions = {}
return _bundled_versions
def _get_installed_version(package_name: str) -> str:
"""Ermittelt die installierte Version eines Pakets."""
bundled = _load_bundled_versions()
if bundled:
return bundled.get(package_name.lower(), "")
try:
return version(package_name)
except PackageNotFoundError:
return ""
def parse_license_file(license_file: Path | None = None) -> ParsedLicenses:
"""
Parst THIRD_PARTY_LICENSES.txt und gibt strukturierte Einträge zurück.
Args:
license_file: Pfad zur Lizenzdatei. Standard: LICENSE_FILE
Returns:
ParsedLicenses mit allen gefundenen Einträgen inkl. installierter Versionen
"""
if license_file is None:
license_file = LICENSE_FILE
if not license_file.exists():
logger.warning(f"Lizenzdatei nicht gefunden: {license_file}")
return ParsedLicenses()
text = license_file.read_text(encoding="utf-8")
lines = text.splitlines()
result = ParsedLicenses()
current_category = ""
current_entry: LicenseEntry | None = None
last_key: str | None = None # Letzter erkannter Schlüssel (für Fortsetzungszeilen)
separator_line = "=" * 20 # Mindestlänge für Sektions-Trennlinien
i = 0
while i < len(lines):
line = lines[i]
# Sektions-Trennlinie erkannt → nächste Zeile ist Sektionsname
if line.startswith(separator_line):
i += 1
if i < len(lines):
section_name = lines[i].strip()
# Bei "Lizenztexte" aufhören — ab hier kommen nur noch Volltexte
if section_name == "Lizenztexte":
# Letzten Eintrag noch sichern
if current_entry:
result.entries.append(current_entry)
break
# Neue Kategorie setzen (ignoriere den Datei-Header "THIRD PARTY LICENSES")
if section_name != "THIRD PARTY LICENSES":
current_category = section_name
# Schließende Trennlinie der Sektion überspringen
i += 1
if i < len(lines) and lines[i].startswith(separator_line):
i += 1
continue
# Nummerierter Eintrag (z.B. "1. PySide6")
match = _ENTRY_PATTERN.match(line.strip())
if match:
# Vorherigen Eintrag speichern
if current_entry:
result.entries.append(current_entry)
entry_name = match.group(1).strip()
current_entry = LicenseEntry(name=entry_name, category=current_category)
# Installierte Version ermitteln
pypi_name = _normalize_package_name(entry_name)
current_entry.installed_version = _get_installed_version(pypi_name)
i += 1
continue
# Schlüssel-Wert-Zeile innerhalb eines Eintrags (z.B. " Version: >=6.10.1")
stripped = line.strip()
if current_entry and ":" in stripped:
key, _, value = stripped.partition(":")
key = key.strip()
value = value.strip()
if key in _KNOWN_KEYS:
last_key = key
if key == "Lizenz":
current_entry.license = value
elif key == "Webseite":
current_entry.website = value
elif key == "GitHub" and not current_entry.website:
# GitHub nur als Fallback wenn keine Webseite vorhanden
current_entry.website = value
elif key == "Beschreibung":
current_entry.description = value
elif key == "Copyright":
current_entry.copyright = value
else:
last_key = None
elif current_entry and stripped and not stripped.startswith("="):
# Fortsetzungszeile (z.B. mehrzeiliger Copyright-Eintrag)
if last_key == "Copyright":
current_entry.copyright += "\n" + stripped
i += 1
# Letzten Eintrag sichern (falls Datei nicht mit "Lizenztexte" endet)
if current_entry and current_entry not in result.entries:
result.entries.append(current_entry)
logger.info(f"{len(result.entries)} Lizenzeinträge aus {license_file.name} geladen")
return result
if __name__ == "__main__":
# Standalone-Test
logging.basicConfig(level=logging.DEBUG)
parsed = parse_license_file()
for entry in parsed.entries:
ver = entry.installed_version or ""
logger.debug(f"[{entry.category}] {entry.name}: {ver} ({entry.license})")