scripts/check_licenses.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""Lizenz-Wächter: keine (A)GPL-/LGPL-/SSPL-Abhängigkeit, keine unbekannte Lizenz.
Nodvard Deck steht unter der PolyForm Noncommercial License 1.0.0; eine kommerzielle Nutzung
soll später gesondert erlaubt werden können. Das geht nur, wenn jede mitgelieferte Abhängigkeit selbst
freizügig lizenziert ist -- eine Copyleft-Abhängigkeit würde verlangen, das ganze
Programm unter ihre Lizenz zu stellen. Geprüft werden (wie in `scripts/third_party_licenses.py`):
* alle Python-Pakete aus `deploy/constraints.txt` (installierte Metadaten),
* alle ausgelieferten npm-Pakete (Production-Dependencies des Frontends und der
Extension-Bundles, Lizenzangabe aus `frontend/package-lock.json`),
* die in Wheels fest eingebauten Bibliotheken (`_PY_EMBEDDED` in `scripts/third_party_licenses.py`), samt
Prüfung, dass der Eintrag zur Paketversion in `deploy/constraints.txt` passt.
Erlaubt: MIT, BSD (alle Varianten), Apache-2.0, ISC, PSF-2.0/Python-2.0, Unlicense, 0BSD,
Zlib sowie MPL-2.0 (nur UNVERÄNDERT verwenden; erscheint als Hinweis im Bericht).
"A OR B" ist erlaubt, wenn mindestens eine Option erlaubt ist (dulwich: Apache-2.0 OR GPL
-> Apache-2.0 gilt). "A AND B": beide müssen erlaubt sein.
Was nicht eindeutig ist (falsche/unklare Metadaten, bewusst geprüfte Sonderfälle), steht
mit Begründung in `EXCEPTIONS`. Eine Ausnahme gilt nur für die dort genannte Version:
ändert sich die Version, schlägt der Wächter wieder an und die Lizenz wird neu geprüft.
python scripts/check_licenses.py # Exit 0 = sauber, 1 = Verstoß, 2 = Umgebung unvollständig
python scripts/check_licenses.py --liste # zusätzlich jedes Paket mit Lizenz auflisten
"""
from __future__ import annotations
import argparse
import re
import sys
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import third_party_licenses as tpl
REPO_ROOT = tpl.REPO_ROOT
@dataclass(frozen=True)
class Ausnahme:
version: str # nur diese Version wurde geprüft
bewertet_als: str # wie wir die Lizenz einordnen
grund: str
# Schlüssel: (Ökosystem, Paketname klein; Python nach PEP 503 normalisiert).
EXCEPTIONS: dict[tuple[str, str], Ausnahme] = {
("python", "asyncssh"): Ausnahme(
version="2.24.0",
bewertet_als="EPL-2.0 (Option von 'EPL-2.0 OR GPL-2.0-or-later')",
grund=(
"Doppellizenz EPL-2.0 oder GPL. Wir nehmen die EPL-Option: schwaches Datei-Copyleft, "
"asyncssh wird unverändert als Bibliothek eingebunden (kein Patch, kein Kopieren von "
"Dateien). Steht nicht auf der Erlaubt-Liste, deshalb bewusst hier eingetragen."
),
),
("python", "defusedxml"): Ausnahme(
version="0.7.1",
bewertet_als="PSF-2.0",
grund=(
"Die Metadaten nennen nur die Abkürzung 'PSFL'; Klassifikator und Lizenzdatei im Wheel sind die "
"Python Software Foundation License Version 2 (PSF-2.0, freizügig, kein Copyleft, steht auf der "
"Erlaubt-Liste). Nur die Schreibweise wird nicht erkannt."
),
),
("python", "pillow"): Ausnahme(
version="13.0.0",
bewertet_als="MIT-CMU (HPND-Familie, permissiv)",
grund=(
"Die Pillow-Lizenz heißt in SPDX 'MIT-CMU' (früher HPND): MIT-ähnlich, freizügig, "
"kein Copyleft. Steht nicht wörtlich auf der Erlaubt-Liste."
),
),
("python", "pyrage"): Ausnahme(
version="1.4.0",
bewertet_als="MIT (plus gebündelte Rust-Crates unter MIT/Apache-2.0/BSD/Unicode)",
grund=(
"Die Metadaten nennen keine Lizenz, die Lizenzdatei im Wheel (dist-info/licenses/LICENSE) und das "
"README sagen MIT. Die einkompilierten Rust-Crates (rage/age u. a.) stehen laut mitgelieferter "
"SBOM (dist-info/sboms/pyrage.cyclonedx.json) unter MIT, Apache-2.0, BSD, Unlicense-oder-MIT bzw. "
"Unicode-3.0; die eine Doppellizenz 'Apache-2.0 OR GPL-2.0-only' nutzen wir als Apache-2.0. Die Liste "
"der Crates mit Lizenz und Urheber steht in THIRD_PARTY_LICENSES."
),
),
("python", "pypdfium2"): Ausnahme(
version="5.13.0",
bewertet_als="BSD-3-Clause AND Apache-2.0 (plus gebündelte freizügige Bibliotheken)",
grund=(
"Die Metadaten nennen 'BSD-3-Clause, Apache-2.0, dependency licenses' (keine SPDX-Formel). "
"Gemeint sind pypdfium2 selbst (Apache-2.0/BSD-3) und die im PDFium-Binärpaket gebündelten "
"Bibliotheken (PDFium, FreeType, ICU, libjpeg-turbo, libpng, zlib, lcms2, OpenJPEG, abseil ...), "
"alle freizügig; ihre Texte stehen in THIRD_PARTY_LICENSES."
),
),
}
# Reihenfolge = Schweregrad (kleiner ist besser).
OK, HINWEIS, UNBEKANNT, COPYLEFT = 0, 1, 2, 3
_NAMES = {OK: "erlaubt", HINWEIS: "erlaubt mit Hinweis", UNBEKANNT: "unbekannt", COPYLEFT: "Copyleft"}
_ALLOWED_IDS = {"mit", "mit-0", "0bsd", "isc", "apache-2.0", "psf-2.0", "python-2.0", "python-2.0.1", "unlicense", "zlib"}
_NOTE_IDS = {"mpl-2.0"}
_BSD_RE = re.compile(r"^bsd(-\d-clause(-[a-z0-9]+)*)?$")
_COPYLEFT_RE = re.compile(
r"(?<![a-z])(a|l)?gpl|sspl|\bgnu\b|affero|lesser general|server side public|general public license"
)
# Freitext-Angaben älterer Pakete (Classifier, License-Feld) auf SPDX-Kennungen abbilden.
_ALIASES: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"^(the )?mit( license)?( \(mit\))?$"), "mit"),
(re.compile(r"^apache[- ]?(software )?(license)?[, ]*(version |v)?(2\.0|2)?$"), "apache-2.0"),
(re.compile(r"^(new |simplified |modified )?bsd( license| licence)?( \(.*\))?$"), "bsd-3-clause"),
(re.compile(r"^isc( license)?( \(iscl\))?$"), "isc"),
(re.compile(r"^(python software foundation license|psf|psf license)$"), "psf-2.0"),
(re.compile(r"^(mozilla public license( 2\.0)?( \(mpl 2\.0\))?|mpl 2\.0)$"), "mpl-2.0"),
(re.compile(r"^(the )?unlicense( \(unlicense\))?$"), "unlicense"),
(re.compile(r"^zlib(/libpng)?( license)?( \(zlib\))?$"), "zlib"),
]
@dataclass(frozen=True)
class Verdict:
rank: int
hinweise: frozenset[str] = frozenset()
schlimmster: str = "" # der Teil der Lizenzangabe, an dem es hängt
def canonical(phrase: str) -> str:
low = re.sub(r"\s+", " ", phrase.strip().lower())
for pattern, spdx in _ALIASES:
if pattern.match(low):
return spdx
return low
def classify_token(phrase: str) -> Verdict:
name = canonical(phrase)
if _COPYLEFT_RE.search(name):
return Verdict(COPYLEFT, schlimmster=phrase.strip())
if name in _ALLOWED_IDS or _BSD_RE.match(name):
return Verdict(OK)
if name in _NOTE_IDS:
return Verdict(HINWEIS, frozenset({"MPL-2.0"}))
return Verdict(UNBEKANNT, schlimmster=phrase.strip())
_TOKEN_RE = re.compile(r"\(|\)|,|;|\b(?:AND|OR|WITH)\b|\b(?:and|or|with)\b|[^\s(),;]+")
_OPERATORS = {"AND", "OR", "WITH", ",", ";", "(", ")"}
def _tokens(expression: str) -> list[str]:
"""Operatoren einzeln, dazwischen zusammenhängende Wörter zu einer Bezeichnung
('Apache Software License' bleibt ein Stück)."""
out: list[str] = []
words: list[str] = []
for raw in _TOKEN_RE.findall(expression):
up = raw.upper()
if up in _OPERATORS:
if words:
out.append(" ".join(words))
words = []
out.append(up)
else:
words.append(raw)
if words:
out.append(" ".join(words))
return out
class _Parser:
def __init__(self, tokens: list[str]):
self.tokens = tokens
self.pos = 0
def peek(self) -> str | None:
return self.tokens[self.pos] if self.pos < len(self.tokens) else None
def take(self) -> str:
token = self.tokens[self.pos]
self.pos += 1
return token
def parse_or(self) -> Verdict:
options = [self.parse_and()]
while self.peek() == "OR":
self.take()
options.append(self.parse_and())
# Die beste Option gilt; bei Gleichstand die mit weniger Hinweisen.
return min(options, key=lambda v: (v.rank, len(v.hinweise)))
def parse_and(self) -> Verdict:
parts = [self.parse_with()]
while self.peek() in ("AND", ",", ";"):
self.take()
parts.append(self.parse_with())
worst = max(parts, key=lambda v: v.rank)
hinweise = frozenset().union(*(v.hinweise for v in parts))
return Verdict(worst.rank, hinweise, worst.schlimmster)
def parse_with(self) -> Verdict:
verdict = self.parse_atom()
if self.peek() == "WITH": # 'Apache-2.0 WITH LLVM-exception': es zählt die Lizenz davor
self.take()
if self.peek() not in (None, ")"):
self.take()
return verdict
def parse_atom(self) -> Verdict:
token = self.peek()
if token is None:
raise ValueError("unvollständiger Ausdruck")
if token == "(":
self.take()
verdict = self.parse_or()
if self.peek() != ")":
raise ValueError("Klammer nicht geschlossen")
self.take()
return verdict
if token in _OPERATORS:
raise ValueError(f"unerwarteter Operator {token!r}")
return classify_token(self.take())
_FORMULA_RE = re.compile(r";|\b(AND|OR|WITH)\b", re.IGNORECASE)
def evaluate_license(declared: str) -> Verdict:
"""Bewertet eine Lizenzangabe (SPDX-Formel oder Freitext) nach der Erlaubt-Liste."""
declared = (declared or "").strip()
if not declared:
return Verdict(UNBEKANNT, schlimmster="(keine Lizenzangabe)")
if not _FORMULA_RE.search(declared):
whole = classify_token(declared) # ganze Angabe als Freitext ('Apache License, Version 2.0')
if whole.rank != UNBEKANNT:
return whole
try:
parser = _Parser(_tokens(declared))
verdict = parser.parse_or()
if parser.peek() is not None:
raise ValueError("Rest nach dem Ausdruck")
return verdict
except (ValueError, IndexError):
return Verdict(UNBEKANNT, schlimmster=declared)
# ---------------------------------------------------------------------------
# Prüfung
# ---------------------------------------------------------------------------
@dataclass
class Finding:
ecosystem: str
name: str
version: str
license: str
verdict: Verdict
exception: Ausnahme | None = None
exception_problem: str = ""
@property
def failed(self) -> bool:
if self.exception_problem:
return True
if self.exception is not None:
return False
return self.verdict.rank >= UNBEKANNT
@dataclass
class Report:
findings: list[Finding] = field(default_factory=list)
env_problems: list[str] = field(default_factory=list)
unused_exceptions: list[str] = field(default_factory=list)
skipped: list[str] = field(default_factory=list) # auf dieser Plattform nicht installiert (Marker), kein Fehler
@property
def violations(self) -> list[Finding]:
return [f for f in self.findings if f.failed]
@property
def notes(self) -> list[Finding]:
return [f for f in self.findings if not f.failed and f.verdict.hinweise]
def exception_key(ecosystem: str, name: str) -> tuple[str, str]:
return ecosystem, (tpl.pep503(name) if ecosystem == "python" else name.lower())
def check_packages(packages: list[tpl.Package], exceptions: dict[tuple[str, str], Ausnahme] | None = None) -> Report:
exceptions = EXCEPTIONS if exceptions is None else exceptions
report = Report()
used: set[tuple[str, str]] = set()
for pkg in packages:
if pkg.skipped:
# Z. B. uvloop unter Windows: wird dort nie installiert, die Lizenz prüft der Lauf unter Linux/CI.
report.skipped.append(f"{pkg.name} {pkg.version}: übersprungen ({pkg.skipped})")
used.add(exception_key(pkg.ecosystem, pkg.name))
continue
for problem in pkg.problems:
report.env_problems.append(f"[{pkg.ecosystem}] {pkg.name} {pkg.version}: {problem}")
if not pkg.license and not pkg.version:
continue # reiner Problem-Platzhalter (Lockfile-Fehler), oben gemeldet
if any("nicht installiert" in p for p in pkg.problems):
continue # Lizenz nicht lesbar, Umgebungsproblem ist schon gemeldet
finding = Finding(pkg.ecosystem, pkg.name, pkg.version, pkg.license, evaluate_license(pkg.license))
key = exception_key(pkg.ecosystem, pkg.name)
exc = exceptions.get(key)
if exc is not None:
used.add(key)
if exc.version == pkg.version:
finding.exception = exc
else:
finding.exception_problem = (
f"Die Ausnahme in scripts/check_licenses.py gilt für Version {exc.version}, "
f"eingesetzt wird {pkg.version}."
)
report.findings.append(finding)
report.unused_exceptions = [f"{eco}:{name}" for (eco, name) in sorted(exceptions) if (eco, name) not in used]
report.findings.sort(key=lambda f: (f.ecosystem, f.name.lower()))
return report
def embedded_packages(pins: list[tuple[str, str]]) -> list[tpl.Package]:
"""Fest eingebaute Bibliotheken (`_PY_EMBEDDED`) als eigene Einträge, damit auch ihre Lizenz geprüft wird.
Gilt der Eintrag für eine andere Paketversion als die in `deploy/constraints.txt` (oder steht das Paket
dort nicht mehr), wird das wie bei `scripts/third_party_licenses.py` als Problem der Umgebung gemeldet
(Exit 2): Die Lizenzangabe wurde dann für eine andere Version geprüft."""
packages = []
for lib in tpl._PY_EMBEDDED:
where = ", ".join(pkg for pkg, _ in lib.pins)
pkg = tpl.Package("python", f"{lib.name} (eingebaut in {where})", lib.version, lib.license, "_PY_EMBEDDED")
pkg.problems += tpl.embedded_pin_problems(pins, (lib,))
packages.append(pkg)
return packages
def collect_packages(repo: Path = REPO_ROOT) -> list[tpl.Package]:
pins = tpl.read_constraints(repo / "deploy" / "constraints.txt")
python_packages = tpl.collect_python(pins)
npm_packages = tpl.collect_npm(repo, with_texts=False)
return python_packages + embedded_packages(pins) + npm_packages
def violation_message(finding: Finding) -> str:
"""Deutsche Fehlermeldung: Paket, Lizenz, was zu tun ist."""
where = f"{finding.name} {finding.version} ({'Python' if finding.ecosystem == 'python' else 'npm'})"
if finding.exception_problem:
return (
f"{where}: Lizenz '{finding.license}'. {finding.exception_problem}\n"
f" Was tun: Lizenz der neuen Version prüfen. Passt sie weiterhin, die Version im Eintrag "
f"EXCEPTIONS (scripts/check_licenses.py) anpassen; sonst das Paket ersetzen."
)
if finding.verdict.rank == COPYLEFT:
return (
f"{where}: steht unter '{finding.license}' (Copyleft: GPL/AGPL/LGPL/SSPL). Das verträgt sich nicht "
f"mit der Lizenz von Nodvard Deck (PolyForm Noncommercial, kommerzielle Erlaubnis später möglich).\n"
f" Was tun: Paket durch ein freizügig lizenziertes ersetzen oder entfernen. Bietet es "
f"eine freizügige Option an (z. B. 'Apache-2.0 OR GPL'), diese in der Lizenzangabe des Pakets prüfen."
)
detail = f" (nicht erkannt: '{finding.verdict.schlimmster}')" if finding.verdict.schlimmster else ""
return (
f"{where}: Lizenz '{finding.license or 'keine Angabe'}' ist unbekannt oder nicht erlaubt{detail}.\n"
f" Was tun: Lizenz des Pakets nachlesen. Ist sie freizügig (MIT/BSD/Apache/ISC ...) und nur die Metadaten "
f"unklar, das Paket mit Begründung und Version in EXCEPTIONS (scripts/check_licenses.py) eintragen; "
f"sonst das Paket ersetzen."
)
def license_overview(report: Report) -> list[tuple[str, int]]:
counter = Counter(f.license or "(keine Angabe)" for f in report.findings)
return sorted(counter.items(), key=lambda item: (-item[1], item[0].lower()))
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Prüft die Lizenzen aller ausgelieferten Abhängigkeiten.")
parser.add_argument("--liste", action="store_true", help="jedes Paket mit Lizenz und Bewertung auflisten")
args = parser.parse_args(argv)
report = check_packages(collect_packages())
if report.env_problems:
print("Die Umgebung ist unvollständig, die Prüfung ist nicht aussagekräftig:", file=sys.stderr)
for problem in report.env_problems:
print(f" - {problem}", file=sys.stderr)
print(
"Abhilfe: `pip install -c deploy/constraints.txt -e sdk/python -e backend` ausführen "
"(bzw. das Paket aus constraints.txt/package-lock.json korrigieren).",
file=sys.stderr,
)
if any("Eintrag für fest eingebaute Bibliothek" in problem for problem in report.env_problems):
print(
"Bei \"Eintrag für fest eingebaute Bibliothek prüfen\" hilft keine Installation: den Eintrag in "
"`_PY_EMBEDDED` (scripts/third_party_licenses.py) prüfen, so wie in der Meldung beschrieben.",
file=sys.stderr,
)
for line in report.skipped:
print(f"Hinweis: {line}")
if args.liste:
by_license: dict[str, list[str]] = defaultdict(list)
for f in report.findings:
by_license[f.license or "(keine Angabe)"].append(f"{f.name} {f.version}")
for lic, names in sorted(by_license.items(), key=lambda item: item[0].lower()):
print(f"{lic}: {', '.join(sorted(names, key=str.lower))}")
print()
summary = ", ".join(f"{lic} ({count})" for lic, count in license_overview(report))
print(f"{len(report.findings)} Pakete geprüft. Lizenzen: {summary}")
for finding in report.findings:
if finding.exception is not None:
print(f"Ausnahme: {finding.name} {finding.version} ({finding.license}) gilt als {finding.exception.bewertet_als}.")
print(f" Grund: {finding.exception.grund}")
for finding in report.notes:
print(f"Hinweis: {finding.name} {finding.version} steht unter MPL-2.0 -- nur unverändert verwenden (Datei-Copyleft).")
for unused in report.unused_exceptions:
print(f"Hinweis: Die Ausnahme für {unused} wird nicht mehr gebraucht -- aus EXCEPTIONS entfernen.")
if report.violations:
print(f"\nFEHLER: {len(report.violations)} Lizenzproblem(e):", file=sys.stderr)
for finding in report.violations:
print(f" - {violation_message(finding)}", file=sys.stderr)
return 1
if report.env_problems:
return 2
print("Lizenzprüfung bestanden.")
return 0
if __name__ == "__main__":
sys.exit(main())