chore: relicense to AGPL-3.0-or-later and add SPDX headers
Replaces LICENSE (GPLv3 -> AGPLv3) and prepends `SPDX-License-Identifier: AGPL-3.0-or-later` to every source file across decnet/, decnet_web/, tests/, scripts/, and tools/. Rationale: closes the GPLv3 ASP loophole so any party operating a modified DECNET as a network service must offer their modified source. Personal copyright (Samuel Paschuan) + inbound=outbound contributions make a future unilateral relicense infeasible. - LICENSE: full AGPL-3.0 text (gnu.org/licenses/agpl-3.0.txt) - COPYRIGHT: project copyright notice - tools/add_spdx_headers.py: idempotent header injector (shebang- and PEP 263-aware) Touches 1565 source files (.py, .ts, .tsx, .js, .jsx, .css, .sh). No behavior change; comments only.
This commit is contained in:
169
tools/add_spdx_headers.py
Normal file
169
tools/add_spdx_headers.py
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Prepend SPDX-License-Identifier headers to every source file in DECNET.
|
||||
|
||||
One-shot tool. Safe to re-run (idempotent: skips files that already have SPDX).
|
||||
|
||||
Rules:
|
||||
- .py / .sh -> '# SPDX-License-Identifier: AGPL-3.0-or-later'
|
||||
- .ts / .tsx / .js / .jsx
|
||||
-> '// SPDX-License-Identifier: AGPL-3.0-or-later'
|
||||
- .css -> '/* SPDX-License-Identifier: AGPL-3.0-or-later */'
|
||||
|
||||
Shebang preservation:
|
||||
- If line 1 starts with '#!', the SPDX header is inserted on line 2.
|
||||
- For .py, if a coding declaration (PEP 263) follows the shebang or sits on
|
||||
line 1/2, the SPDX header is inserted AFTER it.
|
||||
|
||||
Skips: .venv, .311, .git, node_modules, __pycache__, .mypy_cache, dist, build,
|
||||
.next, artifacts, bait, .benchmarks, .pytest_cache, decnet.egg-info,
|
||||
wiki-checkout, and any file matching --exclude.
|
||||
|
||||
Idempotency: a file containing 'SPDX-License-Identifier' anywhere in its first
|
||||
12 lines is left untouched.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SPDX_ID = "SPDX-License-Identifier: AGPL-3.0-or-later"
|
||||
|
||||
COMMENT_BY_EXT: dict[str, str] = {
|
||||
".py": f"# {SPDX_ID}",
|
||||
".sh": f"# {SPDX_ID}",
|
||||
".ts": f"// {SPDX_ID}",
|
||||
".tsx": f"// {SPDX_ID}",
|
||||
".js": f"// {SPDX_ID}",
|
||||
".jsx": f"// {SPDX_ID}",
|
||||
".css": f"/* {SPDX_ID} */",
|
||||
}
|
||||
|
||||
SKIP_DIRS = {
|
||||
".venv",
|
||||
".venv_test",
|
||||
".311",
|
||||
".git",
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".benchmarks",
|
||||
"site-packages",
|
||||
"dist",
|
||||
"build",
|
||||
".next",
|
||||
"artifacts",
|
||||
"bait",
|
||||
"decnet.egg-info",
|
||||
"wiki-checkout",
|
||||
}
|
||||
|
||||
CODING_RE = re.compile(rb"coding[=:]\s*([-\w.]+)")
|
||||
|
||||
|
||||
def is_skipped(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
rel_parts = path.relative_to(root).parts
|
||||
except ValueError:
|
||||
return True
|
||||
return any(part in SKIP_DIRS for part in rel_parts)
|
||||
|
||||
|
||||
def has_spdx(head_lines: list[bytes]) -> bool:
|
||||
for line in head_lines[:12]:
|
||||
if b"SPDX-License-Identifier" in line:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def compute_insert_index(lines: list[bytes], ext: str) -> int:
|
||||
"""Return the index where the SPDX line should be inserted."""
|
||||
idx = 0
|
||||
if lines and lines[0].startswith(b"#!"):
|
||||
idx = 1
|
||||
if ext == ".py":
|
||||
# PEP 263 allows coding decl on line 1 or 2.
|
||||
if idx < len(lines) and CODING_RE.search(lines[idx]):
|
||||
idx += 1
|
||||
return idx
|
||||
|
||||
|
||||
def process_file(path: Path, *, dry_run: bool) -> str:
|
||||
ext = path.suffix
|
||||
header = COMMENT_BY_EXT.get(ext)
|
||||
if header is None:
|
||||
return "skip-ext"
|
||||
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError as e:
|
||||
return f"error:{e}"
|
||||
|
||||
# Preserve trailing-newline state.
|
||||
had_trailing_nl = raw.endswith(b"\n")
|
||||
lines = raw.split(b"\n")
|
||||
# If file ended with \n, split produced a trailing empty element; drop it
|
||||
# for processing and restore at write time.
|
||||
if had_trailing_nl and lines and lines[-1] == b"":
|
||||
lines.pop()
|
||||
|
||||
if has_spdx(lines):
|
||||
return "already"
|
||||
|
||||
insert_at = compute_insert_index(lines, ext)
|
||||
new_lines = lines[:insert_at] + [header.encode("utf-8")] + lines[insert_at:]
|
||||
|
||||
out = b"\n".join(new_lines)
|
||||
if had_trailing_nl or out and not out.endswith(b"\n"):
|
||||
out += b"\n"
|
||||
|
||||
if dry_run:
|
||||
return "would-add"
|
||||
|
||||
path.write_bytes(out)
|
||||
return "added"
|
||||
|
||||
|
||||
def iter_targets(root: Path) -> list[Path]:
|
||||
out: list[Path] = []
|
||||
for p in root.rglob("*"):
|
||||
if not p.is_file():
|
||||
continue
|
||||
if p.suffix not in COMMENT_BY_EXT:
|
||||
continue
|
||||
if is_skipped(p, root):
|
||||
continue
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--root", default=".", help="Repo root (default: cwd)")
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
root = Path(args.root).resolve()
|
||||
targets = iter_targets(root)
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
samples: dict[str, list[Path]] = {}
|
||||
for p in targets:
|
||||
status = process_file(p, dry_run=args.dry_run)
|
||||
counts[status] = counts.get(status, 0) + 1
|
||||
samples.setdefault(status, []).append(p)
|
||||
|
||||
print(f"root: {root}")
|
||||
print(f"total candidates: {len(targets)}")
|
||||
for status, n in sorted(counts.items()):
|
||||
print(f" {status}: {n}")
|
||||
for s in samples[status][:3]:
|
||||
print(f" e.g. {s.relative_to(root)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user