#!/usr/bin/python3# generate debian/copyright from debian/copyright.template and node_modules## Copyright (C) 2025 Red Hat, Inc.# SPDX-License-Identifier: LGPL-2.1-or-laterimport jsonimport reimport sysimport tarfilefrom collections import defaultdictfrom pathlib import Pathfrom typing import Anydef template_licenses(template: str) -> set[str]:"""Return set of existing License: short names"""return {line.split(None, 1)[1].lower()for line in template.splitlines()if line.startswith('License:')}# Patterns for skipping invalid copyright statementsskip_patterns = [# Generic license template textr'^owner', r'^holder', r'^license', r'^notice', r'^statement', r'^law', r'^and', r'^or\b', r'^the\b',r'^contributors?', r'^information', r'^disclaimer',# License boilerplate phrasesr'^//', r'liable', r'warranty', r'condition', r'permission', r'granted',# Template year placeholdersr'\[yyyy\]', r'\{yyyy\}',# Just a year or just numbers/punctuationr'^\d{4}\s*$', r'^[\d\s,;.\-]+$',# Incomplete copyright statementsr'^[\d\s,;.\-]+(All Rights|Reserved)\.?$',]def find_copyright_in_license_text(content: str) -> set[str]:"""Heuristically extract copyrights from LICENSE file content"""def is_valid_copyright(text: str) -> bool:"""Check if copyright text is valid"""if any(re.search(pattern, text, re.IGNORECASE) for pattern in skip_patterns):return False# Only accept if it looks like an actual copyright (has year or name)return (re.search(r'\d{4}', text) or len(text.split()) >= 2) and len(text) < 200return {match.group(1).strip()for match in re.finditer(r'Copyright\s+(?:\(c\)\s*)?(.+)$', content, re.MULTILINE | re.IGNORECASE)if is_valid_copyright(match.group(1).strip())}def normalize_spdx_license(license_text: str, license_ids: set[str]) -> str:"""Normalize license to SPDX identifier using pattern-based substitutions"""# Handle license expressions - normalize to lowercase "and" per Debian spec# https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/#license-short-namelicense_text = license_text.replace(' AND ', ' and ')def normalize_part(part: str) -> str:"""Normalize a single license identifier"""part = part.strip().strip('()')# Strip " License" suffix (e.g., "ISC License" -> "ISC")part = re.sub(r'\s+License$', '', part)# Convert e.g. "GPL-2.0+" → "GPL-2.0-or-later"part = re.sub(r'\+$', '-or-later', part)# Normalize specific licensespart = part.replace('MIT/X11', 'MIT')part = re.sub(r'^Apache[- ]2(?:\.0)?$', 'Apache-2.0', part)part = re.sub(r'^BSD$', 'BSD-3-Clause', part)part = re.sub(r'^Python-2\.0\.1$', 'Python-2.0', part)# Handle -only suffix (e.g., "LGPL-2.1" -> "LGPL-2.1-only")if re.match(r'^(L?GPL)-\d+\.\d+$', part):part += '-only'return part# Split "and"ed licensesparts = [p.strip() for p in license_text.split(' and ')]normalized_parts: list[str] = []for part in parts:normalized = normalize_part(part)if normalized.lower() not in license_ids:sys.exit(f"ERROR: License '{normalized}' is not defined in the template\n"f"Original license text: '{license_text}'\n""Please add a License: paragraph for this license to the template.")normalized_parts.append(normalized)return ' and '.join(normalized_parts)def extract_author_name(author: str | dict[str, Any]) -> str:"""Extract author name from package.json author field"""if isinstance(author, dict):return str(author.get('name', ''))# Parse "Name <email>" formatreturn re.sub(r'\s*<[^>]+>\s*', '', str(author)).strip()def get_legalese(tarball_path: Path, license_ids: set[str]) -> dict[str, tuple[str, set[str]]]:"""Extract licenses and copyrights from node_modules tarball.Returns: {package_path -> (license, copyrights)}"""# Map package_path to license (from package.json)package_licenses: dict[str, str] = {}# Map package_path to copyrightscopyrights: dict[str, set[str]] = {}with tarfile.open(tarball_path) as tar:for member in tar.getmembers():if not member.isfile() or not member.name.startswith('node_modules/'):continuef = tar.extractfile(member)assert fif Path(member.name).name == 'package.json':pkg_data = json.load(f)if pkg_license := pkg_data.get('license'):package_path = str(Path(member.name).parent.relative_to('node_modules'))package_licenses[package_path] = pkg_licenseif author := pkg_data.get('author'):if author_name := extract_author_name(author):copyrights.setdefault(package_path, set()).add(author_name)# Scan all files for copyright statements (associate with top-level package)if copyright_statements := find_copyright_in_license_text(f.read().decode(errors='ignore')):toplevel = member.name.split('/')[1]copyrights.setdefault(toplevel, set()).update(copyright_statements)# Build package legal inforeturn {path: (normalize_spdx_license(lic, license_ids),copyrights.get(path) or copyrights.get(path.split('/')[0], {f"Authors of {path}"}))for path, lic in package_licenses.items()}## main#if len(sys.argv) != 3:sys.exit(f"Usage: {sys.argv[0]} <copyright-template> <node-cache-tarball>")template_file = Path(sys.argv[1])node_cache_path = Path(sys.argv[2])template = template_file.read_text()license_ids = template_licenses(template)packages_legal = get_legalese(node_cache_path, license_ids)# Merge siblings with the same license into their parentby_parent = defaultdict(list)for path in list(packages_legal.keys()):if parent := '/'.join(path.split('/')[:-1]):by_parent[parent].append(path)for parent, children in by_parent.items():if len(children) > 1 and len(licenses := {packages_legal[c][0] for c in children}) == 1:copyrights = set().union(*(packages_legal[c][1] for c in children))for child in children:del packages_legal[child]packages_legal[parent] = (licenses.pop(), copyrights)# Generate paragraphs, skipping entries covered by a parent with the same licenseparagraphs: list[str] = []for package_path in sorted(packages_legal.keys()):license_text, copyrights = packages_legal[package_path]# Check if any parent path has the same license (if so, skip - already covered)covered_by_parent = any(parent_path != package_pathand package_path.startswith(parent_path + '/')and packages_legal[parent_path][0] == license_textfor parent_path in packages_legal)if not covered_by_parent:copyright_text = '\n '.join(sorted(copyrights))paragraphs.append(f"Files: node/{package_path}/*\nCopyright: {copyright_text}\nLicense: {license_text}")# Assemble copyright filenpm_content = '\n\n'.join(paragraphs)print('\n'.join(npm_content if '#NPM' in line else line for line in template.splitlines()))