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