From 80d1a5ad3ec862c64a3bbe9919d4547340950183 Mon Sep 17 00:00:00 2001 From: Mike Frysinger Date: Thu, 21 Aug 2025 10:40:51 -0400 Subject: run_tests: add file header checker for licensing blocks Change-Id: Ic0bfa3b03e2ba46d565a5bc2c1b7a7463b7dca2c Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/500103 Commit-Queue: Mike Frysinger Tested-by: Mike Frysinger Reviewed-by: Scott Lee --- release/check-metadata.py | 152 ++++++++++++++++++++++++++++++++++++++++++++++ release/util.py | 9 +-- run_tests | 10 +++ 3 files changed, 167 insertions(+), 4 deletions(-) create mode 100755 release/check-metadata.py diff --git a/release/check-metadata.py b/release/check-metadata.py new file mode 100755 index 00000000..e17932da --- /dev/null +++ b/release/check-metadata.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# Copyright (C) 2025 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Helper tool to check various metadata (e.g. licensing) in source files.""" + +import argparse +from pathlib import Path +import re +import sys + +import util + + +_FILE_HEADER_RE = re.compile( + r"""# Copyright \(C\) 20[0-9]{2} The Android Open Source Project +# +# Licensed under the Apache License, Version 2\.0 \(the "License"\); +# you may not use this file except in compliance with the License\. +# You may obtain a copy of the License at +# +# http://www\.apache\.org/licenses/LICENSE-2\.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied\. +# See the License for the specific language governing permissions and +# limitations under the License\. +""" +) + + +def check_license(path: Path, lines: list[str]) -> bool: + """Check license header.""" + # Enforce licensing on configs & scripts. + if not ( + path.suffix in (".bash", ".cfg", ".ini", ".py", ".toml") + or lines[0] in ("#!/bin/bash", "#!/bin/sh", "#!/usr/bin/env python3") + ): + return True + + # Extract the file header. + header_lines = [] + for line in lines: + if line.startswith("#"): + header_lines.append(line) + else: + break + if not header_lines: + print( + f"error: {path.relative_to(util.TOPDIR)}: " + "missing file header (copyright+licensing)", + file=sys.stderr, + ) + return False + + # Skip the shebang. + if header_lines[0].startswith("#!"): + header_lines.pop(0) + + # If this file is imported into the tree, then leave it be. + if header_lines[0] == "# DO NOT EDIT THIS FILE": + return True + + header = "".join(f"{x}\n" for x in header_lines) + if not _FILE_HEADER_RE.match(header): + print( + f"error: {path.relative_to(util.TOPDIR)}: " + "file header incorrectly formatted", + file=sys.stderr, + ) + print( + "".join(f"> {x}\n" for x in header_lines), end="", file=sys.stderr + ) + return False + + return True + + +def check_path(opts: argparse.Namespace, path: Path) -> bool: + """Check a single path.""" + data = path.read_text(encoding="utf-8") + lines = data.splitlines() + # NB: Use list comprehension and not a generator so we run all the checks. + return all( + [ + check_license(path, lines), + ] + ) + + +def check_paths(opts: argparse.Namespace, paths: list[Path]) -> bool: + """Check all the paths.""" + # NB: Use list comprehension and not a generator so we check all paths. + return all([check_path(opts, x) for x in paths]) + + +def find_files(opts: argparse.Namespace) -> list[Path]: + """Find all the files in the source tree.""" + result = util.run( + opts, + ["git", "ls-tree", "-r", "-z", "--name-only", "HEAD"], + cwd=util.TOPDIR, + capture_output=True, + encoding="utf-8", + ) + return [util.TOPDIR / x for x in result.stdout.split("\0")[:-1]] + + +def get_parser() -> argparse.ArgumentParser: + """Get a CLI parser.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "-n", + "--dry-run", + dest="dryrun", + action="store_true", + help="show everything that would be done", + ) + parser.add_argument( + "paths", + nargs="*", + help="the paths to scan", + ) + return parser + + +def main(argv: list[str]) -> int: + """The main func!""" + parser = get_parser() + opts = parser.parse_args(argv) + + paths = opts.paths + if not opts.paths: + paths = find_files(opts) + + return 0 if check_paths(opts, paths) else 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/release/util.py b/release/util.py index c839b872..8596324f 100644 --- a/release/util.py +++ b/release/util.py @@ -14,7 +14,7 @@ """Random utility code for release tools.""" -import os +from pathlib import Path import re import shlex import subprocess @@ -24,8 +24,9 @@ import sys assert sys.version_info >= (3, 6), "This module requires Python 3.6+" -TOPDIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -HOMEDIR = os.path.expanduser("~") +THIS_FILE = Path(__file__).resolve() +TOPDIR = THIS_FILE.parent.parent +HOMEDIR = Path("~").expanduser() # These are the release keys we sign with. @@ -54,7 +55,7 @@ def run(opts, cmd, check=True, **kwargs): def import_release_key(opts): """Import the public key of the official release repo signing key.""" # Extract the key from our repo launcher. - launcher = getattr(opts, "launcher", os.path.join(TOPDIR, "repo")) + launcher = getattr(opts, "launcher", TOPDIR / "repo") print(f'Importing keys from "{launcher}" launcher script') with open(launcher, encoding="utf-8") as fp: data = fp.read() diff --git a/run_tests b/run_tests index 4720ac20..04f2deb4 100755 --- a/run_tests +++ b/run_tests @@ -102,6 +102,15 @@ def run_isort(): ).returncode +def run_check_metadata(): + """Returns the exit code from check-metadata.""" + return subprocess.run( + [sys.executable, "release/check-metadata.py"], + check=False, + cwd=ROOT_DIR, + ).returncode + + def run_update_manpages() -> int: """Returns the exit code from release/update-manpages.""" # Allow this to fail on CI, but not local devs. @@ -124,6 +133,7 @@ def main(argv): run_black, run_flake8, run_isort, + run_check_metadata, run_update_manpages, ) # Run all the tests all the time to get full feedback. Don't exit on the -- cgit v1.2.3-54-g00ecf