blob: 1ab3ccc050d555dee82c5400835f10710984c9ee [file]
#!/usr/bin/env python3
# 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.
"""Generate the third-party section of LICENSE-wheel.
The wheel ships one piece of content that is not Burr source: the compiled
tracking UI at ``burr/tracking/server/build/assets/index-*.js``. That bundle
contains code from a large number of npm packages, and ASF policy requires
the binary distribution's LICENSE to reflect what the binary actually
contains.
Rather than maintain that list by hand (it drifts silently on every
dependency bump), this script derives it from the build itself: it asks vite
for a sourcemap, reads the ``sources`` array to find every ``node_modules``
file that was compiled into the bundle, resolves each one to its owning
package, and emits a license section listing each package with its license
and copyright.
Everything in LICENSE-wheel above the generated marker is hand-maintained and
left untouched; everything from the marker to end-of-file is regenerated.
Usage::
# regenerate LICENSE-wheel in place (requires telemetry/ui/node_modules)
python scripts/generate_wheel_license.py
# fail if LICENSE-wheel is out of date -- for CI
python scripts/generate_wheel_license.py --check
# reuse a sourcemap from an earlier build instead of rebuilding
python scripts/generate_wheel_license.py --sourcemap path/to/index-XXXX.js.map
"""
from __future__ import annotations
import argparse
import collections
import glob
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
UI_DIR = os.path.join(REPO_ROOT, "telemetry", "ui")
LICENSE_WHEEL = os.path.join(REPO_ROOT, "LICENSE-wheel")
MARKER = "THIRD-PARTY COMPONENTS BUNDLED IN THIS BINARY DISTRIBUTION"
RULE = "=" * 70
SEPARATOR = "-" * 70
COPYRIGHT_RE = re.compile(r"^.*copyright.*$", re.IGNORECASE | re.MULTILINE)
def build_sourcemap(out_dir: str) -> str:
"""Run a sourcemap build of the UI and return the path to the JS map."""
if not os.path.isdir(os.path.join(UI_DIR, "node_modules")):
sys.exit(
f"error: {UI_DIR}/node_modules is missing.\n"
"Run `npm ci` in telemetry/ui first, or pass --sourcemap."
)
subprocess.run(
["npx", "vite", "build", "--sourcemap", "--outDir", out_dir],
cwd=UI_DIR,
check=True,
stdout=subprocess.DEVNULL,
)
maps = glob.glob(os.path.join(out_dir, "assets", "*.js.map"))
if not maps:
sys.exit(f"error: no .js.map produced under {out_dir}/assets")
return max(maps, key=os.path.getsize)
def bundled_packages(sourcemap_path: str) -> dict[str, str]:
"""Map package name -> package root for everything compiled into the bundle."""
sourcemap = json.load(open(sourcemap_path, encoding="utf-8"))
base = os.path.dirname(os.path.abspath(sourcemap_path))
found: dict[str, str] = {}
for source in sourcemap.get("sources", []):
if "node_modules/" not in source:
continue
directory = os.path.dirname(os.path.normpath(os.path.join(base, source)))
# Climb to the package root. Some packages ship stub package.json files
# in subdirectories (@babel/runtime/helpers/esm, react-icons/fa) that
# carry no "name" -- keep climbing until a named one turns up.
while "node_modules" in directory:
manifest = os.path.join(directory, "package.json")
if os.path.exists(manifest):
try:
name = json.load(open(manifest, encoding="utf-8")).get("name")
except (OSError, ValueError):
name = None
if name:
found.setdefault(name, directory)
break
directory = os.path.dirname(directory)
return found
def read_package(name: str, directory: str) -> dict:
manifest = json.load(open(os.path.join(directory, "package.json"), encoding="utf-8"))
license_id = manifest.get("license") or manifest.get("licenses") or "UNKNOWN"
if isinstance(license_id, list):
license_id = ",".join(
entry.get("type", str(entry)) if isinstance(entry, dict) else str(entry)
for entry in license_id
)
if isinstance(license_id, dict):
license_id = license_id.get("type", "UNKNOWN")
text = None
for filename in sorted(os.listdir(directory)):
if filename.upper().split(".")[0] in ("LICENSE", "LICENCE", "COPYING"):
try:
text = open(
os.path.join(directory, filename), encoding="utf-8", errors="replace"
).read()
except OSError:
text = None
break
return {
"name": name,
"version": manifest.get("version", ""),
"license": str(license_id),
"text": text,
}
def copyright_lines(text: str | None) -> list[str]:
if not text:
return []
out = []
for line in COPYRIGHT_RE.findall(text):
line = line.strip().strip("*").strip()
# Skip all-caps fragments of the warranty disclaimer, which also
# contain the word COPYRIGHT ("...COPYRIGHT HOLDERS BE LIABLE...").
if not line or line.isupper() or "copyright ownership" in line.lower():
continue
if line.lower().startswith(("copyright", "(c)", "©")):
out.append(line)
seen: set[str] = set()
return [line for line in out if not (line in seen or seen.add(line))]
def canonical_text(text: str | None) -> str:
"""License body with copyright lines stripped, so identical bodies collapse."""
if not text:
return ""
body = " ".join(line for line in text.splitlines() if not COPYRIGHT_RE.match(line))
return re.sub(r"\s+", " ", body).strip()
def render(packages: list[dict]) -> str:
by_license: dict[str, list[dict]] = collections.defaultdict(list)
for package in packages:
by_license[package["license"]].append(package)
lines: list[str] = []
emit = lines.append
emit(RULE)
emit(MARKER)
emit("(generated by scripts/generate_wheel_license.py -- do not edit by hand)")
emit(RULE)
emit("")
emit("The file burr/tracking/server/build/assets/index-*.js in this wheel is")
emit("a compiled bundle produced from telemetry/ui. In addition to Apache")
emit("Burr's own source, it contains code from the third-party packages")
emit("listed below, under the licenses shown. None of these packages ships")
emit("a NOTICE file, so no NOTICE content is carried over from them.")
emit("")
for license_id in sorted(by_license):
group = sorted(by_license[license_id], key=lambda p: p["name"])
emit(SEPARATOR)
emit(f"The following components are licensed under the {license_id} license:")
emit("")
for package in group:
if package["text"]:
emit(f" {package['name']}@{package['version']}")
else:
emit(f" {package['name']}@{package['version']}")
emit(f" [no license file shipped; declared as {license_id} in package.json]")
for line in copyright_lines(package["text"])[:3]:
emit(f" {line}")
emit("")
bodies: dict[str, str] = {}
for package in group:
if package["text"]:
bodies.setdefault(canonical_text(package["text"]), package["text"])
for index, body in enumerate(bodies.values(), 1):
label = f"{license_id} license text"
if len(bodies) > 1:
label += f" (variant {index})"
emit(f" --- {label} ---")
emit("")
for line in body.rstrip().splitlines():
emit(f" {line}" if line.strip() else "")
emit("")
return "\n".join(lines).rstrip() + "\n"
def splice(existing: str, generated: str) -> str:
"""Replace everything from the generated marker onward."""
index = existing.find(RULE + "\n" + MARKER)
head = existing if index == -1 else existing[:index]
return head.rstrip() + "\n\n" + generated
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--check",
action="store_true",
help="exit non-zero if LICENSE-wheel is out of date instead of rewriting it",
)
parser.add_argument(
"--sourcemap",
help="use an existing *.js.map instead of running a fresh vite build",
)
args = parser.parse_args()
temp_dir = None
try:
if args.sourcemap:
sourcemap_path = args.sourcemap
else:
temp_dir = tempfile.mkdtemp(prefix="burr-license-build-")
sourcemap_path = build_sourcemap(temp_dir)
found = bundled_packages(sourcemap_path)
if not found:
sys.exit("error: no node_modules sources found in the sourcemap")
packages = [read_package(name, path) for name, path in sorted(found.items())]
finally:
if temp_dir:
shutil.rmtree(temp_dir, ignore_errors=True)
generated = render(packages)
existing = open(LICENSE_WHEEL, encoding="utf-8").read()
updated = splice(existing, generated)
unknown = [p["name"] for p in packages if p["license"] in ("UNKNOWN", "")]
print(f"bundled npm packages: {len(packages)}")
counts = collections.Counter(p["license"] for p in packages)
for license_id, count in sorted(counts.items(), key=lambda kv: -kv[1]):
print(f" {count:4d} {license_id}")
if unknown:
print(f"warning: no license declared for: {', '.join(unknown)}", file=sys.stderr)
if args.check:
if updated != existing:
print(
"\nLICENSE-wheel is out of date. Run:\n"
" python scripts/generate_wheel_license.py",
file=sys.stderr,
)
return 1
print("\nLICENSE-wheel is up to date.")
return 0
if updated == existing:
print("\nLICENSE-wheel already up to date.")
return 0
open(LICENSE_WHEEL, "w", encoding="utf-8").write(updated)
print(f"\nwrote {LICENSE_WHEEL}")
return 0
if __name__ == "__main__":
raise SystemExit(main())