mirror of
https://github.com/azahar-emu/azahar.git
synced 2026-11-13 09:39:44 +01:00
401 lines
14 KiB
Python
Executable File
401 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# Copyright 2026 Citra Emulator Project / Azahar Emulator Project
|
|
# Licensed under GPLv3 or any later version
|
|
# Refer to the LICENSE.txt file included.
|
|
|
|
"""
|
|
Python script that updates the license headers to match
|
|
the expected format.
|
|
|
|
Only two kinds of headers can be rewritten automatically:
|
|
* the old Citra-only header, for example:
|
|
// Copyright 2014 Citra Emulator Project
|
|
// Licensed under GPLv2 or any later version
|
|
// Refer to the license.txt file included.
|
|
|
|
* the old GPLv2 header used by Azahar, for example:
|
|
// Copyright 2018-2025 Citra Emulator Project / Azahar Emulator Project
|
|
// Licensed under GPLv2 or any later version
|
|
// Refer to the misc/licenses/gplv2.txt file included.
|
|
|
|
Furthermore, this tool updates the year range of the license header,
|
|
for exampe: "2018-2025" -> "2018-2026"
|
|
|
|
Headers that do not follow those formats are reported for manual review
|
|
as they require manual modification. Files with a missing header are
|
|
reported as well, and only fixed automatically if --add-missing is
|
|
passed.
|
|
|
|
The tool only checks license headers for those files that changed compared
|
|
to the master branch, with uncommitted and untracked files included. Use
|
|
--all to check all files, or pass individual files to only check them.
|
|
|
|
Use --read-only-run to only show changes and not write anything.
|
|
|
|
Usage (from anywhere inside the repository):
|
|
./tools/fix-license-headers.py # files changed vs master
|
|
./tools/fix-license-headers.py src/core/foo.cpp src/core/bar.h
|
|
./tools/fix-license-headers.py --all # every file in the tree
|
|
./tools/fix-license-headers.py --read-only-run # show changes, write nothing
|
|
"""
|
|
|
|
import argparse
|
|
import datetime
|
|
import difflib
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
CHECKED_EXTENSIONS = (
|
|
".cpp", ".cpp.in", ".frag", ".h", ".h.in", ".inc", ".kt", ".kts", ".m", ".mm", ".vert",
|
|
)
|
|
|
|
NEW_HEADER_TEMPLATE = (
|
|
"// Copyright {years} Citra Emulator Project / Azahar Emulator Project\n"
|
|
"// Licensed under GPLv3 or any later version\n"
|
|
"// Refer to the LICENSE.txt file included.\n"
|
|
)
|
|
|
|
CURRENT_HEADER_RE = re.compile(
|
|
r"\A// Copyright (?P<start>\d{4})(?:-(?P<end>\d{4}))? "
|
|
r"Citra Emulator Project / Azahar Emulator Project\n"
|
|
r"// Licensed under GPLv3 or any later version\n"
|
|
r"// Refer to the LICENSE\.txt file included\.\n"
|
|
)
|
|
|
|
# Old headers that can be converted automatically. The owner must be exactly
|
|
# "Citra Emulator Project" or "Citra Emulator Project / Azahar Emulator
|
|
# Project". Anything else does not match.
|
|
OLD_HEADER_RE = re.compile(
|
|
r"\A//[ \t]*Copyright[ \t]+(?P<start>\d{4})(?:[ \t]*-[ \t]*(?P<end>\d{4}))?[ \t]+"
|
|
r"(?P<owner>Citra Emulator Project(?:[ \t]*/[ \t]*Azahar Emulator Project)?)[ \t]*\n"
|
|
r"//[ \t]*Licensed under (?P<license>GPLv2 or any later version|GPLv2\+|GPLv3 or any later version)[ \t]*\n"
|
|
r"//[ \t]*Refer to the (?:license\.txt|LICENSE\.txt|misc/licenses/gplv2\.txt) file included\.{1,2}[ \t]*\n"
|
|
)
|
|
|
|
# Words that indicate some form of license/copyright notice near the top of a
|
|
# file. If one of them appears and the header did not match above, the file has
|
|
# a license we must not touch.
|
|
LICENSE_HINT_RE = re.compile(
|
|
r"copyright|licen[cs]e|spdx|public domain|permission is hereby granted|gpl|\(c\)",
|
|
re.IGNORECASE,
|
|
)
|
|
HINT_SCAN_LINES = 30
|
|
|
|
BOM = "\ufeff"
|
|
|
|
# Helpers to print the diffs
|
|
|
|
USE_COLOR = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
|
|
|
|
def color(text, code):
|
|
return f"\033[{code}m{text}\033[0m" if USE_COLOR else text
|
|
|
|
|
|
def bold(text):
|
|
return color(text, "1")
|
|
|
|
|
|
def red(text):
|
|
return color(text, "31")
|
|
|
|
|
|
def green(text):
|
|
return color(text, "32")
|
|
|
|
|
|
def yellow(text):
|
|
return color(text, "33")
|
|
|
|
|
|
def cyan(text):
|
|
return color(text, "36")
|
|
|
|
|
|
def print_diff(path, old_text, new_text):
|
|
diff = difflib.unified_diff(
|
|
old_text.splitlines(keepends=True),
|
|
new_text.splitlines(keepends=True),
|
|
fromfile=f"a/{path}",
|
|
tofile=f"b/{path}",
|
|
n=1,
|
|
)
|
|
for line in diff:
|
|
line = line.rstrip("\n")
|
|
if line.startswith(("+++", "---")):
|
|
print(bold(line))
|
|
elif line.startswith("@@"):
|
|
print(cyan(line))
|
|
elif line.startswith("+"):
|
|
print(green(line))
|
|
elif line.startswith("-"):
|
|
print(red(line))
|
|
else:
|
|
print(line)
|
|
|
|
|
|
# Helpers to interface with git
|
|
|
|
def git(*args, cwd=None):
|
|
result = subprocess.run(
|
|
["git", *args], cwd=cwd, capture_output=True, text=True, check=False
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"git {' '.join(args)} failed:\n{result.stderr.strip()}")
|
|
return result.stdout
|
|
|
|
|
|
def repo_root():
|
|
try:
|
|
return git("rev-parse", "--show-toplevel").strip()
|
|
except (RuntimeError, FileNotFoundError):
|
|
sys.exit("error: this script must be run from inside the Azahar git repository")
|
|
|
|
|
|
def resolve_base(root, base):
|
|
candidates = [base] if base else ["master", "origin/master"]
|
|
for candidate in candidates:
|
|
try:
|
|
git("rev-parse", "--verify", "--quiet", candidate + "^{commit}", cwd=root)
|
|
return candidate
|
|
except RuntimeError:
|
|
continue
|
|
sys.exit(
|
|
f"error: could not find the base branch ({' or '.join(candidates)}).\n"
|
|
"Run 'git fetch origin master', pass --base, or list files explicitly."
|
|
)
|
|
|
|
|
|
def get_changed_files(root, base):
|
|
merge_base = git("merge-base", base, "HEAD", cwd=root).strip()
|
|
# Committed + staged + unstaged changes since the branch point.
|
|
files = git("diff", "--name-only", "--diff-filter=d", merge_base, cwd=root).split("\n")
|
|
# New files that have not been added yet.
|
|
files += git("ls-files", "--others", "--exclude-standard", cwd=root).split("\n")
|
|
return sorted({f for f in files if f})
|
|
|
|
|
|
def get_all_files(root):
|
|
return sorted(f for f in git("ls-files", cwd=root).split("\n") if f)
|
|
|
|
|
|
# Header process helpers
|
|
|
|
def format_years(start, end, year):
|
|
start = int(start)
|
|
end = max(int(end) if end else start, year)
|
|
start = min(start, end)
|
|
return str(end) if start == end else f"{start}-{end}"
|
|
|
|
|
|
class Result:
|
|
FIXED = "fixed"
|
|
ADDED = "added"
|
|
MISSING = "missing"
|
|
OK = "ok"
|
|
MANUAL = "manual"
|
|
ERROR = "error"
|
|
|
|
def __init__(self, path, status, old_text=None, new_text=None, note=None, has_bom=False):
|
|
self.path = path
|
|
self.status = status
|
|
self.old_text = old_text
|
|
self.new_text = new_text
|
|
self.note = note
|
|
self.has_bom = has_bom
|
|
|
|
|
|
def process_file(root, path, year, add_missing):
|
|
full_path = os.path.join(root, path)
|
|
try:
|
|
with open(full_path, "rb") as f:
|
|
raw = f.read()
|
|
text = raw.decode("utf-8")
|
|
except (OSError, UnicodeDecodeError) as e:
|
|
return Result(path, Result.ERROR, note=str(e))
|
|
|
|
has_bom = text.startswith(BOM)
|
|
body = text[len(BOM):] if has_bom else text
|
|
|
|
match = CURRENT_HEADER_RE.match(body)
|
|
if match:
|
|
years = format_years(match["start"], match["end"], year)
|
|
current = match["start"] if not match["end"] else f"{match['start']}-{match['end']}"
|
|
if years == current:
|
|
return Result(path, Result.OK)
|
|
new_body = NEW_HEADER_TEMPLATE.format(years=years) + body[match.end():]
|
|
return Result(path, Result.FIXED, body, new_body,
|
|
note="year range updated", has_bom=has_bom)
|
|
|
|
match = OLD_HEADER_RE.match(body)
|
|
if match:
|
|
years = format_years(match["start"], match["end"], year)
|
|
new_body = NEW_HEADER_TEMPLATE.format(years=years) + body[match.end():]
|
|
kind = "old Citra-only header" if "Azahar" not in match["owner"] else "old GPLv2 header"
|
|
if match["license"].startswith("GPLv3"):
|
|
kind = "outdated header"
|
|
return Result(path, Result.FIXED, body, new_body, note=kind, has_bom=has_bom)
|
|
|
|
head = "\n".join(body.split("\n")[:HINT_SCAN_LINES])
|
|
if LICENSE_HINT_RE.search(head):
|
|
return Result(path, Result.MANUAL, body,
|
|
note="has a license header that cannot be updated automatically")
|
|
|
|
if add_missing:
|
|
new_body = NEW_HEADER_TEMPLATE.format(years=year) + "\n" + body
|
|
return Result(path, Result.ADDED, body, new_body,
|
|
note="no license header found, header added", has_bom=has_bom)
|
|
return Result(path, Result.MISSING, body, note="no license header found")
|
|
|
|
|
|
def write_result(root, result):
|
|
text = (BOM if result.has_bom else "") + result.new_text
|
|
with open(os.path.join(root, result.path), "w", encoding="utf-8", newline="") as f:
|
|
f.write(text)
|
|
|
|
DISCLAIMER = """\
|
|
================================================================================
|
|
IMPORTANT
|
|
================================================================================
|
|
This tool rewrites license headers. Changing a license header has legal
|
|
implications, by applying these changes YOU are stating that the new header
|
|
(GPLv3 or any later version, Citra Emulator Project / Azahar Emulator Project)
|
|
correctly describes the licensing of each of the files listed above.
|
|
|
|
You are responsible for these changes. Please review every change above before
|
|
continuing, and do not accept them if any file contains code that is not
|
|
owned by the Citra / Azahar projects or that you are not sure about.
|
|
================================================================================"""
|
|
|
|
|
|
def confirm():
|
|
print(yellow(DISCLAIMER))
|
|
try:
|
|
answer = input(bold('Type "yes" to accept responsibility and apply the changes: '))
|
|
except (EOFError, KeyboardInterrupt):
|
|
print()
|
|
return False
|
|
return answer.strip().lower() == "yes"
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Update old Citra/Azahar license headers to the current GPLv3 header "
|
|
"format.",
|
|
)
|
|
parser.add_argument("paths", nargs="*",
|
|
help="files or directories to check (default: files changed vs the base branch)")
|
|
parser.add_argument("--all", action="store_true",
|
|
help="check every tracked file in the repository")
|
|
parser.add_argument("--base", metavar="REF",
|
|
help="branch to compare against (default: master, then origin/master)")
|
|
parser.add_argument("--year", type=int, default=datetime.date.today().year,
|
|
help="year the header range must include (defult: current year)")
|
|
parser.add_argument("--add-missing", action="store_true",
|
|
help="add the header to files that have no license header at all")
|
|
parser.add_argument("--read-only-run", action="store_true",
|
|
help="only show the chnages without asking to apply them")
|
|
args = parser.parse_args()
|
|
|
|
root = repo_root()
|
|
|
|
if args.paths:
|
|
files = []
|
|
for p in args.paths:
|
|
abs_p = os.path.abspath(p)
|
|
if os.path.isdir(abs_p):
|
|
rel = os.path.relpath(abs_p, root)
|
|
files += git("ls-files", "--cached", "--others", "--exclude-standard",
|
|
"--", rel, cwd=root).split("\n")
|
|
else:
|
|
files.append(os.path.relpath(abs_p, root))
|
|
files = sorted({f for f in files if f})
|
|
source = "the given paths"
|
|
elif args.all:
|
|
files = get_all_files(root)
|
|
source = "all tracked files"
|
|
else:
|
|
base = resolve_base(root, args.base)
|
|
files = get_changed_files(root, base)
|
|
source = f"files changed relative to {base}"
|
|
|
|
candidates = [f for f in files
|
|
if f.endswith(CHECKED_EXTENSIONS) and os.path.isfile(os.path.join(root, f))]
|
|
skipped = [f for f in files if args.paths and f not in candidates]
|
|
|
|
print(f"Checking {len(candidates)} files from {source} (year {args.year})\n")
|
|
for f in skipped:
|
|
print(f" skipped (not a file type checked by the CI or missing): {f}")
|
|
if skipped:
|
|
print()
|
|
|
|
results = [process_file(root, f, args.year, args.add_missing) for f in candidates]
|
|
changes = [r for r in results if r.status in (Result.FIXED, Result.ADDED)]
|
|
manual = [r for r in results if r.status == Result.MANUAL]
|
|
missing = [r for r in results if r.status == Result.MISSING]
|
|
errors = [r for r in results if r.status == Result.ERROR]
|
|
ok = [r for r in results if r.status == Result.OK]
|
|
|
|
if changes:
|
|
print(bold(f"Proposed changes ({len(changes)} files):\n"))
|
|
for r in changes:
|
|
print(bold(f"{r.path}") + f" ({r.note})")
|
|
print_diff(r.path, r.old_text, r.new_text)
|
|
print()
|
|
|
|
if manual:
|
|
print(bold(yellow(f"Needs manual review ({len(manual)} files), NOT modified:")))
|
|
print("These files have a license header that cannot be updated automatically.\n"
|
|
"Decide by hand how they should be adjusted.\n")
|
|
for r in manual:
|
|
print(f" {yellow(r.path)}")
|
|
for line in r.old_text.split("\n")[:4]:
|
|
print(f" | {line}")
|
|
print()
|
|
|
|
if missing:
|
|
print(bold(yellow(f"No license header ({len(missing)} files), NOT modified:")))
|
|
for r in missing:
|
|
print(f" {yellow(r.path)}")
|
|
print("Run with --add-missing to add the header to these files or add it by hand.\n")
|
|
|
|
if errors:
|
|
print(bold(red(f"Could not read ({len(errors)} files):")))
|
|
for r in errors:
|
|
print(f" {red(r.path)}: {r.note}")
|
|
print()
|
|
|
|
print(f"Summary: {len(changes)} to update, {len(ok)} already correct, "
|
|
f"{len(manual)} need manual review, {len(missing)} without a header, "
|
|
f"{len(errors)} unreadable.")
|
|
|
|
if manual or missing:
|
|
print("\nExpected header for files owned by the Azahar project:")
|
|
print(NEW_HEADER_TEMPLATE.format(years=args.year), end="")
|
|
|
|
needs_attention = bool(manual or missing or errors)
|
|
|
|
if not changes:
|
|
return 1 if needs_attention else 0
|
|
|
|
if args.read_only_run:
|
|
print("\nRead only run: no files were modified.")
|
|
return 1
|
|
|
|
print()
|
|
if not confirm():
|
|
print(red("Aborted. No files were modified."))
|
|
return 1
|
|
|
|
print()
|
|
for r in changes:
|
|
write_result(root, r)
|
|
print(green("updated: ") + r.path)
|
|
print(f"\n{len(changes)} files updated. Manual review is recommended.")
|
|
return 1 if needs_attention else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|