#!/usr/bin/env python3 # This file is part of Eigen, a lightweight C++ template library # for linear algebra. # # Copyright (C) 2026 The Eigen Authors # # This Source Code Form is subject to the terms of the Mozilla # Public License v. 2.0. If a copy of the MPL was not distributed # with this file, You can obtain one at http://mozilla.org/MPL/2.0/. # SPDX-License-Identifier: MPL-2.0 # REUSE-IgnoreStart """ Insert per-file SPDX license tags across the Eigen tree. Idempotent: files already containing an SPDX-License-Identifier tag are left untouched. Re-running after a clean run produces no changes. The license id assigned to each file is determined by `classify(path)`: - LAPACKE/BLAS bridges with Intel copyright -> BSD-3-Clause - MORSE-derived cmake/Find*.cmake modules -> BSD-3-Clause - Reference-BLAS Fortran tests blas/testing/*.f -> BSD-3-Clause - LevenbergMarquardt MINPACK-derived headers -> MPL-2.0 AND LicenseRef-MINPACK - Everything else under repo -> MPL-2.0 The comment style is determined by the file extension: - C / C++ / CUDA / Inc / Fortran-fixed-form ('//', '#', '*' etc.) Files explicitly excluded from inline SPDX (handled instead by REUSE.toml): - LICENSES/, COPYING.*, LICENSE (license texts) - *.dox, *.in, *.md, *.dat, *.png, README*, INSTALL, (assets/templates) *.def, *.natvis, *.css, *.xsl, *.preamble, *.traits, *.krazy - .git*, .clang*, .coderabbit*, .gitlab/ (config dotfiles) - failtest/*.cpp (trivial stubs) - signature_of_eigen3_matrix_library (sentinel file) """ # REUSE-IgnoreEnd from __future__ import annotations import argparse import re import subprocess import sys from pathlib import Path from typing import Iterable REPO_ROOT = Path(__file__).resolve().parent.parent SPDX_RE = re.compile(r"SPDX-License-Identifier\s*:") # Match REUSE's own copyright detection (case-sensitive `Copyright`); lowercase # `copyright` lines (typo in some headers) won't be recognised, so we'll still # inject `SPDX-FileCopyrightText` for those files. COPYRIGHT_RE = re.compile(r"Copyright\b") # --------------------------------------------------------------------------- # Path classification # --------------------------------------------------------------------------- # Files containing an Intel BSD-3-Clause copyright header (LAPACKE/BLAS bridges). # Match by content rather than path so this stays correct as files are added. INTEL_BSD_MARKER = b"Copyright (c) 2011, Intel Corporation" INTEL_BSD_MARKER_2010 = b"Copyright (c) 2010, Intel Corp" # MORSE-derived cmake modules (Univ. of Tennessee 2009-2014). MORSE_MARKER = b"@copyright (c) 2009-2014 The University of Tennessee" # Header marker for the LevenbergMarquardt MINPACK-derived files. MINPACK_MARKER = b"Minpack license" # Reference-BLAS / LAPACK Fortran (Univ. of Tennessee/Berkeley/Colorado Denver). REFBLAS_MARKERS = ( b"Reference BLAS is a software package", b"LAPACK is a software package provided by Univ. of Tennessee", ) # Apache-2.0 — TensorFlow-derived BFloat16, Codeplay FindComputeCpp.cmake. APACHE_MARKER = b"Licensed under the Apache License" def in_failtest(rel: Path) -> bool: return rel.parts and rel.parts[0] == "failtest" # Bulk-mapped via REUSE.toml — never insert inline SPDX into these. SKIP_BY_NAME = { "CHANGELOG.md", "INSTALL", "README.md", "signature_of_eigen3_matrix_library", "CLAUDE.md", } SKIP_BY_SUFFIX = { ".dox", ".in", ".dat", ".png", ".svg", ".jpg", ".jpeg", ".gif", ".md", ".txt", # README.txt, *.txt config — handled by REUSE.toml ".def", # blas/eigen_blas.def, lapack/eigen_lapack*.def ".natvis", ".css", ".xsl", ".preamble", ".traits", ".krazy", ".yaml", # .coderabbit.yaml — REUSE.toml ".entry", # doc/examples/make_circulant.cpp.entry / .evaluator / ... ".evaluator", ".expression", ".main", } # Top-level / specially-handled excluded paths (relative to repo root). SKIP_PATHS = { ".clang-format", ".clang-tidy", ".coderabbit.yaml", ".git-blame-ignore-revs", ".gitattributes", ".gitignore", "LICENSE", "REUSE.toml", } def is_skipped(rel: Path) -> bool: name = rel.name posix = rel.as_posix() if posix in SKIP_PATHS: return True if posix.startswith("LICENSES/") or posix.startswith("COPYING"): return True if posix.startswith(".gitlab/") or posix.startswith(".git/"): return True if in_failtest(rel) and rel.suffix == ".cpp": return True # Recognise CMakeLists.txt by name BEFORE the .txt suffix-skip below. if name == "CMakeLists.txt": return False if name in SKIP_BY_NAME: return True if rel.suffix in SKIP_BY_SUFFIX: return True return False def classify(rel: Path, content: bytes) -> str: """Return the SPDX-License-Identifier expression for a file.""" if INTEL_BSD_MARKER in content or INTEL_BSD_MARKER_2010 in content: return "BSD-3-Clause" if MORSE_MARKER in content: return "BSD-3-Clause" if MINPACK_MARKER in content: return "MPL-2.0 AND LicenseRef-MINPACK" if any(m in content for m in REFBLAS_MARKERS): return "BSD-3-Clause" if APACHE_MARKER in content: return "Apache-2.0" return "MPL-2.0" # --------------------------------------------------------------------------- # Comment-style dispatch # --------------------------------------------------------------------------- # (suffix -> comment prefix). Files without a known suffix are handled by name. SLASH_LANGUAGES = {".h", ".hh", ".cpp", ".cc", ".c", ".cu", ".inc", ".js"} HASH_LANGUAGES = {".cmake", ".sh", ".py", ".ps1", ".yml", ".cfg"} FORTRAN_LANGUAGES = {".f", ".F", ".f90"} def is_eigen_umbrella_header(rel: Path) -> bool: """`Eigen/Core`, `unsupported/Eigen/Tensor`, etc. — extensionless C++ headers.""" parts = rel.parts if rel.suffix: return False if len(parts) >= 2 and parts[0] == "Eigen" and parts[-1][:1].isupper(): return len(parts) == 2 if ( len(parts) >= 3 and parts[0] == "unsupported" and parts[1] == "Eigen" and parts[-1][:1].isupper() ): # unsupported/Eigen/Tensor, ... return True return False def has_shebang(path: Path) -> bool: try: with path.open("rb") as f: return f.read(2) == b"#!" except OSError: return False def comment_prefix_for(rel: Path, full: Path | None = None) -> str | None: if rel.suffix in SLASH_LANGUAGES: return "//" if rel.suffix in HASH_LANGUAGES: return "#" if rel.suffix in FORTRAN_LANGUAGES: return "*>" name = rel.name if name == "CMakeLists.txt": return "#" if is_eigen_umbrella_header(rel): return "//" # Extensionless executable scripts (e.g. scripts/eigen_gen_docs). if not rel.suffix and full is not None and has_shebang(full): return "#" return None # --------------------------------------------------------------------------- # Insertion strategy # --------------------------------------------------------------------------- def insert_spdx( path: Path, content: bytes, spdx_id: str, comment: str ) -> bytes | None: """Return new file bytes with the SPDX line inserted, or None for no-op.""" full_text = content.decode("utf-8", errors="replace") if SPDX_RE.search(full_text): return None # Already tagged. head = full_text[:4096] has_copyright = COPYRIGHT_RE.search(head) is not None # Match the file's line-ending style (CRLF vs LF) so we don't introduce # mixed endings that clang-format will then "fix". eol = "\r\n" if b"\r\n" in content[:4096] else "\n" # REUSE-IgnoreStart spdx_lines = [] if not has_copyright: spdx_lines.append(f"{comment} SPDX-FileCopyrightText: The Eigen Authors{eol}") spdx_lines.append(f"{comment} SPDX-License-Identifier: {spdx_id}{eol}") spdx_line = "".join(spdx_lines).encode() # REUSE-IgnoreEnd lines = full_text.splitlines(keepends=True) # Two header families to consider: # # (a) Existing C-line-comment MPL prose ("// This file is part of Eigen, ... # // ... obtain one at http://mozilla.org/MPL/2.0/."): append SPDX # directly after the prose block (last consecutive comment line at # top-of-file). # # (b) Existing C-block-comment header (Intel "/* Copyright (c) 2011, Intel # Corporation ... */"): append a comment line right after the closing # "*/". # # (c) Hash-comment prose (.cmake / .gitlab-ci.yml / etc.): append after the # last consecutive '#'-comment line at top. # # (d) Fortran '*'-comment prose (Reference BLAS): append a `*>` line after # the last consecutive '*'-prefixed line at top. # # (e) No header: insert SPDX as the very first line, then a blank line. # # Files starting with `#!shebang` or a `#ifndef` guard — we always insert # the SPDX line above the guard but below any shebang. insert_at = 0 has_shebang = lines and lines[0].startswith("#!") if has_shebang: insert_at = 1 # (b) C-block comment opening at index `insert_at`. if insert_at < len(lines) and lines[insert_at].lstrip().startswith("/*"): for i in range(insert_at, len(lines)): if "*/" in lines[i]: insert_at = i + 1 # Skip a single blank line after the block, if any. break new_block = spdx_line.decode() # We want SPDX as a // line after the */. return ("".join(lines[:insert_at]) + new_block + "".join(lines[insert_at:])).encode() # (a, c, d) Detect a top-of-file run of comment lines and insert after. end_of_block = insert_at if comment == "//": marker_prefixes = ("//",) elif comment == "#": marker_prefixes = ("#",) elif comment == "*>": # Fortran free-form file headers begin with `*` or `*>`. marker_prefixes = ("*",) else: marker_prefixes = (comment,) while end_of_block < len(lines): s = lines[end_of_block].lstrip() if any(s.startswith(p) for p in marker_prefixes): end_of_block += 1 continue break if end_of_block > insert_at: # Append SPDX after the existing prose block. return ( "".join(lines[:end_of_block]) + spdx_line.decode() + "".join(lines[end_of_block:]) ).encode() # (e) No header — insert at the top (after shebang if present), and # follow with a single blank line *unless* the file already starts with # one (otherwise clang-format collapses the duplicate). next_is_blank = ( insert_at < len(lines) and lines[insert_at].strip() == "" ) blank = "" if next_is_blank else eol return ( "".join(lines[:insert_at]) + spdx_line.decode() + blank + "".join(lines[insert_at:]) ).encode() # --------------------------------------------------------------------------- # Driver # --------------------------------------------------------------------------- def tracked_files() -> Iterable[Path]: out = subprocess.check_output( ["git", "ls-files"], cwd=REPO_ROOT, text=True ) for line in out.splitlines(): if line: yield Path(line) def main() -> int: p = argparse.ArgumentParser(description=__doc__) p.add_argument( "--check", action="store_true", help="Don't write; exit 1 if any file would change.", ) p.add_argument( "--paths", nargs="*", help="Limit to these paths (relative to repo root). Default: all tracked.", ) p.add_argument( "-v", "--verbose", action="store_true", help="Print every file decision (skip / classify / change).", ) args = p.parse_args() if args.paths: targets = [Path(p) for p in args.paths] else: targets = list(tracked_files()) changed = [] for rel in targets: if is_skipped(rel): if args.verbose: print(f"[skip-rule] {rel}") continue full = REPO_ROOT / rel prefix = comment_prefix_for(rel, full) if prefix is None: if args.verbose: print(f"[skip-ext] {rel}") continue try: content = full.read_bytes() except (FileNotFoundError, IsADirectoryError): continue spdx_id = classify(rel, content) new_content = insert_spdx(rel, content, spdx_id, prefix) if new_content is None: if args.verbose: print(f"[ok] {rel} ({spdx_id})") continue if args.check: print(f"[would-change] {rel} ({spdx_id})") changed.append(rel) continue full.write_bytes(new_content) print(f"[changed] {rel} ({spdx_id})") changed.append(rel) if args.check and changed: print(f"\n{len(changed)} file(s) need SPDX headers.", file=sys.stderr) return 1 if not args.check: print(f"\nUpdated {len(changed)} file(s).") return 0 if __name__ == "__main__": sys.exit(main())