Files
OpenShell/scripts/generate_third_party_notices.py

225 lines
7.2 KiB
Python
Executable File

#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Generate THIRD-PARTY-NOTICES from Rust and Python dependency metadata.
Usage:
python scripts/generate_third_party_notices.py
Writes THIRD-PARTY-NOTICES to the repo root. Requires `cargo` on PATH
for Rust deps. Python deps are read from pyproject.toml.
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
def find_repo_root() -> Path:
"""Walk up from CWD to find the directory containing .git."""
path = Path.cwd()
while path != path.parent:
if (path / ".git").exists():
return path
path = path.parent
return Path.cwd()
# Workspace member crate names to exclude (these are ours, not third-party).
WORKSPACE_PREFIXES = ("navigator-",)
def get_rust_deps_from_cargo_metadata() -> list[dict[str, str]] | None:
"""Try to extract third-party Rust deps via `cargo metadata`."""
try:
result = subprocess.run(
["cargo", "metadata", "--format-version", "1"],
capture_output=True,
text=True,
check=True,
)
except (FileNotFoundError, subprocess.CalledProcessError):
return None
meta = json.loads(result.stdout)
workspace_members = set(meta.get("workspace_members", []))
deps = []
for pkg in meta["packages"]:
if pkg["id"] in workspace_members:
continue
if any(pkg["name"].startswith(p) for p in WORKSPACE_PREFIXES):
continue
deps.append({
"name": pkg["name"],
"version": pkg["version"],
"license": pkg.get("license") or "Unknown",
"repository": pkg.get("repository") or "",
})
return sorted(deps, key=lambda d: d["name"].lower())
def get_rust_deps_from_lockfile(root: Path) -> list[dict[str, str]]:
"""Fallback: parse Cargo.lock for name+version (no license info)."""
lockfile = root / "Cargo.lock"
if not lockfile.exists():
return []
deps = []
content = lockfile.read_text()
name = None
version = None
for line in content.splitlines():
if line.startswith("name = "):
name = line.split('"')[1]
elif line.startswith("version = ") and '"' in line:
version = line.split('"')[1]
elif line == "[[package]]" or line == "":
if name and version:
if not any(name.startswith(p) for p in WORKSPACE_PREFIXES):
deps.append({
"name": name,
"version": version,
"license": "See crates.io",
"repository": f"https://crates.io/crates/{name}",
})
name = None
version = None
# Catch the last entry.
if name and version:
if not any(name.startswith(p) for p in WORKSPACE_PREFIXES):
deps.append({
"name": name,
"version": version,
"license": "See crates.io",
"repository": f"https://crates.io/crates/{name}",
})
return sorted(deps, key=lambda d: d["name"].lower())
def get_rust_deps(root: Path) -> list[dict[str, str]]:
"""Extract third-party Rust dependencies.
Prefers `cargo metadata` for full license info. Falls back to parsing
Cargo.lock when cargo is not available.
"""
deps = get_rust_deps_from_cargo_metadata()
if deps is not None:
return deps
print(" cargo not found, falling back to Cargo.lock parsing", file=sys.stderr)
return get_rust_deps_from_lockfile(root)
def get_python_deps(root: Path) -> list[dict[str, str]]:
"""Extract Python dependencies from pyproject.toml [project.dependencies]."""
pyproject = root / "pyproject.toml"
if not pyproject.exists():
return []
# Simple parser: read the dependencies list without a TOML library.
content = pyproject.read_text()
deps = []
in_deps = False
for line in content.splitlines():
stripped = line.strip()
if stripped.startswith("dependencies = ["):
in_deps = True
continue
if in_deps:
if stripped == "]":
break
# Parse "package>=version" style.
dep = stripped.strip('",').strip()
if dep:
# Split on first version specifier.
for sep in (">=", "==", "~=", "!=", "<", ">"):
if sep in dep:
name = dep[:dep.index(sep)].strip()
deps.append({
"name": name,
"version": dep[dep.index(sep):].strip(),
"license": "See PyPI",
"repository": f"https://pypi.org/project/{name}/",
})
break
else:
deps.append({
"name": dep,
"version": "",
"license": "See PyPI",
"repository": f"https://pypi.org/project/{dep}/",
})
return sorted(deps, key=lambda d: d["name"].lower())
def format_notices(rust_deps: list[dict], python_deps: list[dict]) -> str:
"""Format the THIRD-PARTY-NOTICES file content."""
lines = [
"THIRD-PARTY SOFTWARE NOTICES",
"",
"This file lists the third-party software packages used by OpenShell,",
"along with their respective licenses.",
"",
"To regenerate: uv run python scripts/generate_third_party_notices.py",
"",
]
if rust_deps:
lines.append("=" * 80)
lines.append("Rust Dependencies")
lines.append("=" * 80)
lines.append("")
for dep in rust_deps:
lines.append(f"Package: {dep['name']} {dep['version']}")
lines.append(f"License: {dep['license']}")
if dep["repository"]:
lines.append(f"Repository: {dep['repository']}")
lines.append("")
if python_deps:
lines.append("=" * 80)
lines.append("Python Dependencies")
lines.append("=" * 80)
lines.append("")
for dep in python_deps:
version_str = f" {dep['version']}" if dep["version"] else ""
lines.append(f"Package: {dep['name']}{version_str}")
lines.append(f"License: {dep['license']}")
if dep["repository"]:
lines.append(f"Repository: {dep['repository']}")
lines.append("")
return "\n".join(lines)
def main() -> int:
root = find_repo_root()
print("Collecting Rust dependencies...")
rust_deps = get_rust_deps(root)
print(f" Found {len(rust_deps)} Rust dependencies")
print("Collecting Python dependencies...")
python_deps = get_python_deps(root)
print(f" Found {len(python_deps)} Python dependencies")
notices = format_notices(rust_deps, python_deps)
output = root / "THIRD-PARTY-NOTICES"
output.write_text(notices)
print(f"Wrote {output}")
return 0
if __name__ == "__main__":
sys.exit(main())