blob: 3927b83c256a49a7c7b55853f1e79bce9a704822 [file]
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import importlib.metadata
import re
import sys
from pathlib import Path
_CATEGORY_A: frozenset[str] = frozenset(
{
"apache",
"apache-2.0",
"apache software license",
"mit",
"mit license",
"mit no attribution",
"mit-0",
"bsd",
"bsd license",
"bsd-2-clause",
"bsd-3-clause",
"3-clause bsd",
"new bsd license",
"modified bsd license",
"simplified bsd license",
"isc",
"isc license",
"python software foundation",
"psf",
"psf license",
"psf-2.0",
"python license",
"mpl-2.0",
"mozilla public license 2.0",
"public domain",
"unlicense",
"the unlicense",
"cc0",
"zlib",
"zlib/libpng",
"artistic license 2.0",
"w3c license",
}
)
_CATEGORY_X: frozenset[str] = frozenset(
{
"gpl",
"gnu general public license",
"agpl",
"gnu affero general public license",
"lgpl",
"gnu lesser general public license",
"copyleft",
"cc-by-sa",
"cc-by-nc",
"eupl",
"european union public licence",
"cddl",
"common development and distribution license",
"cpl",
"common public license",
"osl",
"open software license",
"non-commercial",
"noncommercial",
}
)
_NORM_RE = re.compile(r"[-_.]+")
def _normalize(name: str) -> str:
return _NORM_RE.sub("-", name).lower()
def _collect_direct_deps() -> frozenset[str]:
project_root = Path(__file__).resolve().parent.parent
names: set[str] = set()
for req_file in sorted(project_root.glob("**/requirements*.txt")):
parts = req_file.parts
if any(p.startswith(".") or p in ("venv", ".venv", "env") for p in parts):
continue
for line in req_file.read_text(encoding="utf-8", errors="replace").splitlines():
line = line.strip()
if not line or line.startswith(("#", "-", ".")):
continue
pkg = re.split(r"[>=<!;\s\[]", line)[0].strip()
if pkg:
names.add(_normalize(pkg))
return frozenset(names)
def _license_from_classifiers(meta: importlib.metadata.PackageMetadata) -> str:
classifiers: list[str] = meta.get_all("Classifier") or []
license_tags = [c for c in classifiers if c.startswith("License ::")]
if not license_tags:
return ""
parts = license_tags[0].split(" :: ")
return parts[-1].strip() if len(parts) >= 2 else ""
def _classify(license_text: str) -> str:
normalized = license_text.lower()
for keyword in _CATEGORY_A:
if keyword in normalized:
return "SAFE"
for keyword in _CATEGORY_X:
if keyword in normalized:
return "RESTRICTED"
return "UNKNOWN"
def _scan_direct_deps(direct_dep_names: frozenset[str]) -> list[dict]:
results: list[dict] = []
for dist in importlib.metadata.distributions():
meta = dist.metadata
name: str | None = meta["Name"]
if name is None:
continue
if _normalize(name) not in direct_dep_names:
continue
version: str = meta["Version"] or "unknown"
license_str = (
_license_from_classifiers(meta) or (meta["License"] or "")
).strip() or "UNKNOWN"
results.append(
{
"package": name,
"version": version,
"license": license_str,
"classification": _classify(license_str),
}
)
return sorted(results, key=lambda r: r["package"].lower())
def _print_table(results: list[dict]) -> None:
W = [36, 12, 46, 14]
headers = ["Package", "Version", "License", "Status"]
header_row = " ".join(h.ljust(w) for h, w in zip(headers, W))
print(header_row)
print("-" * len(header_row))
marker_map = {"SAFE": "[OK] ", "RESTRICTED": "[FAIL]", "UNKNOWN": "[WARN]"}
for r in results:
marker = marker_map.get(r["classification"], "[WARN]")
print(
" ".join(
[
r["package"][: W[0] - 1].ljust(W[0]),
r["version"][: W[1] - 1].ljust(W[1]),
r["license"][: W[2] - 1].ljust(W[2]),
f"{marker} {r['classification']}".ljust(W[3]),
]
)
)
def main() -> int:
direct_deps = _collect_direct_deps()
if not direct_deps:
print("No requirements*.txt files found — nothing to check.")
return 0
print(f"Checking {len(direct_deps)} direct dependenc(ies): {', '.join(sorted(direct_deps))}")
print()
results = _scan_direct_deps(direct_deps)
if not results:
print("None of the declared dependencies are currently installed.")
return 0
_print_table(results)
print()
restricted = [r for r in results if r["classification"] == "RESTRICTED"]
unknown = [r for r in results if r["classification"] == "UNKNOWN"]
safe = [r for r in results if r["classification"] == "SAFE"]
if restricted:
print(f"[FAIL] {len(restricted)} Category X (restricted) license(s) detected:")
for r in restricted:
print(f" {r['package']} {r['version']} -> {r['license']}")
print()
print(" These licenses are forbidden in Apache releases.")
print(" See: https://www.apache.org/legal/resolved.html#category-x")
return 1
if unknown:
print(f"[WARN] {len(unknown)} package(s) with unrecognized license metadata:")
for r in unknown:
print(f" {r['package']} {r['version']} -> {r['license']}")
print()
print(" Verify each against https://www.apache.org/legal/resolved.html")
print(" If safe, add the license string to _CATEGORY_A in this script.")
print()
print(
f"[PASS] {len(safe)} safe | {len(unknown)} unrecognized | "
f"0 restricted ({len(results)} total)"
)
return 0
if __name__ == "__main__":
sys.exit(main())