""" Parser für THIRD_PARTY_LICENSES.txt. Extrahiert strukturierte Lizenzinformationen und ergänzt sie mit den tatsächlich installierten Paketversionen via importlib.metadata. """ 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() def _get_installed_version(package_name: str) -> str: """Ermittelt die installierte Version eines Pakets.""" 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})")