diff options
| author | Mike Frysinger <vapier@google.com> | 2025-08-21 10:40:51 -0400 |
|---|---|---|
| committer | LUCI <gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com> | 2025-08-21 11:16:35 -0700 |
| commit | 80d1a5ad3ec862c64a3bbe9919d4547340950183 (patch) | |
| tree | ce00fff8d509cb9292dcd0e42922b8235fde224c | |
| parent | c615c964fb0c40f1ff2b70681336d0d5d89ddcd7 (diff) | |
| download | git-repo-80d1a5ad3ec862c64a3bbe9919d4547340950183.tar.gz | |
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 <vapier@google.com>
Tested-by: Mike Frysinger <vapier@google.com>
Reviewed-by: Scott Lee <ddoman@google.com>
| -rwxr-xr-x | release/check-metadata.py | 152 | ||||
| -rw-r--r-- | release/util.py | 9 | ||||
| -rwxr-xr-x | run_tests | 10 |
3 files changed, 167 insertions, 4 deletions
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 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | # Copyright (C) 2025 The Android Open Source Project | ||
| 3 | # | ||
| 4 | # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| 5 | # you may not use this file except in compliance with the License. | ||
| 6 | # You may obtain a copy of the License at | ||
| 7 | # | ||
| 8 | # http://www.apache.org/licenses/LICENSE-2.0 | ||
| 9 | # | ||
| 10 | # Unless required by applicable law or agreed to in writing, software | ||
| 11 | # distributed under the License is distributed on an "AS IS" BASIS, | ||
| 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| 13 | # See the License for the specific language governing permissions and | ||
| 14 | # limitations under the License. | ||
| 15 | |||
| 16 | """Helper tool to check various metadata (e.g. licensing) in source files.""" | ||
| 17 | |||
| 18 | import argparse | ||
| 19 | from pathlib import Path | ||
| 20 | import re | ||
| 21 | import sys | ||
| 22 | |||
| 23 | import util | ||
| 24 | |||
| 25 | |||
| 26 | _FILE_HEADER_RE = re.compile( | ||
| 27 | r"""# Copyright \(C\) 20[0-9]{2} The Android Open Source Project | ||
| 28 | # | ||
| 29 | # Licensed under the Apache License, Version 2\.0 \(the "License"\); | ||
| 30 | # you may not use this file except in compliance with the License\. | ||
| 31 | # You may obtain a copy of the License at | ||
| 32 | # | ||
| 33 | # http://www\.apache\.org/licenses/LICENSE-2\.0 | ||
| 34 | # | ||
| 35 | # Unless required by applicable law or agreed to in writing, software | ||
| 36 | # distributed under the License is distributed on an "AS IS" BASIS, | ||
| 37 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied\. | ||
| 38 | # See the License for the specific language governing permissions and | ||
| 39 | # limitations under the License\. | ||
| 40 | """ | ||
| 41 | ) | ||
| 42 | |||
| 43 | |||
| 44 | def check_license(path: Path, lines: list[str]) -> bool: | ||
| 45 | """Check license header.""" | ||
| 46 | # Enforce licensing on configs & scripts. | ||
| 47 | if not ( | ||
| 48 | path.suffix in (".bash", ".cfg", ".ini", ".py", ".toml") | ||
| 49 | or lines[0] in ("#!/bin/bash", "#!/bin/sh", "#!/usr/bin/env python3") | ||
| 50 | ): | ||
| 51 | return True | ||
| 52 | |||
| 53 | # Extract the file header. | ||
| 54 | header_lines = [] | ||
| 55 | for line in lines: | ||
| 56 | if line.startswith("#"): | ||
| 57 | header_lines.append(line) | ||
| 58 | else: | ||
| 59 | break | ||
| 60 | if not header_lines: | ||
| 61 | print( | ||
| 62 | f"error: {path.relative_to(util.TOPDIR)}: " | ||
| 63 | "missing file header (copyright+licensing)", | ||
| 64 | file=sys.stderr, | ||
| 65 | ) | ||
| 66 | return False | ||
| 67 | |||
| 68 | # Skip the shebang. | ||
| 69 | if header_lines[0].startswith("#!"): | ||
| 70 | header_lines.pop(0) | ||
| 71 | |||
| 72 | # If this file is imported into the tree, then leave it be. | ||
| 73 | if header_lines[0] == "# DO NOT EDIT THIS FILE": | ||
| 74 | return True | ||
| 75 | |||
| 76 | header = "".join(f"{x}\n" for x in header_lines) | ||
| 77 | if not _FILE_HEADER_RE.match(header): | ||
| 78 | print( | ||
| 79 | f"error: {path.relative_to(util.TOPDIR)}: " | ||
| 80 | "file header incorrectly formatted", | ||
| 81 | file=sys.stderr, | ||
| 82 | ) | ||
| 83 | print( | ||
| 84 | "".join(f"> {x}\n" for x in header_lines), end="", file=sys.stderr | ||
| 85 | ) | ||
| 86 | return False | ||
| 87 | |||
| 88 | return True | ||
| 89 | |||
| 90 | |||
| 91 | def check_path(opts: argparse.Namespace, path: Path) -> bool: | ||
| 92 | """Check a single path.""" | ||
| 93 | data = path.read_text(encoding="utf-8") | ||
| 94 | lines = data.splitlines() | ||
| 95 | # NB: Use list comprehension and not a generator so we run all the checks. | ||
| 96 | return all( | ||
| 97 | [ | ||
| 98 | check_license(path, lines), | ||
| 99 | ] | ||
| 100 | ) | ||
| 101 | |||
| 102 | |||
| 103 | def check_paths(opts: argparse.Namespace, paths: list[Path]) -> bool: | ||
| 104 | """Check all the paths.""" | ||
| 105 | # NB: Use list comprehension and not a generator so we check all paths. | ||
| 106 | return all([check_path(opts, x) for x in paths]) | ||
| 107 | |||
| 108 | |||
| 109 | def find_files(opts: argparse.Namespace) -> list[Path]: | ||
| 110 | """Find all the files in the source tree.""" | ||
| 111 | result = util.run( | ||
| 112 | opts, | ||
| 113 | ["git", "ls-tree", "-r", "-z", "--name-only", "HEAD"], | ||
| 114 | cwd=util.TOPDIR, | ||
| 115 | capture_output=True, | ||
| 116 | encoding="utf-8", | ||
| 117 | ) | ||
| 118 | return [util.TOPDIR / x for x in result.stdout.split("\0")[:-1]] | ||
| 119 | |||
| 120 | |||
| 121 | def get_parser() -> argparse.ArgumentParser: | ||
| 122 | """Get a CLI parser.""" | ||
| 123 | parser = argparse.ArgumentParser(description=__doc__) | ||
| 124 | parser.add_argument( | ||
| 125 | "-n", | ||
| 126 | "--dry-run", | ||
| 127 | dest="dryrun", | ||
| 128 | action="store_true", | ||
| 129 | help="show everything that would be done", | ||
| 130 | ) | ||
| 131 | parser.add_argument( | ||
| 132 | "paths", | ||
| 133 | nargs="*", | ||
| 134 | help="the paths to scan", | ||
| 135 | ) | ||
| 136 | return parser | ||
| 137 | |||
| 138 | |||
| 139 | def main(argv: list[str]) -> int: | ||
| 140 | """The main func!""" | ||
| 141 | parser = get_parser() | ||
| 142 | opts = parser.parse_args(argv) | ||
| 143 | |||
| 144 | paths = opts.paths | ||
| 145 | if not opts.paths: | ||
| 146 | paths = find_files(opts) | ||
| 147 | |||
| 148 | return 0 if check_paths(opts, paths) else 1 | ||
| 149 | |||
| 150 | |||
| 151 | if __name__ == "__main__": | ||
| 152 | 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 @@ | |||
| 14 | 14 | ||
| 15 | """Random utility code for release tools.""" | 15 | """Random utility code for release tools.""" |
| 16 | 16 | ||
| 17 | import os | 17 | from pathlib import Path |
| 18 | import re | 18 | import re |
| 19 | import shlex | 19 | import shlex |
| 20 | import subprocess | 20 | import subprocess |
| @@ -24,8 +24,9 @@ import sys | |||
| 24 | assert sys.version_info >= (3, 6), "This module requires Python 3.6+" | 24 | assert sys.version_info >= (3, 6), "This module requires Python 3.6+" |
| 25 | 25 | ||
| 26 | 26 | ||
| 27 | TOPDIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | 27 | THIS_FILE = Path(__file__).resolve() |
| 28 | HOMEDIR = os.path.expanduser("~") | 28 | TOPDIR = THIS_FILE.parent.parent |
| 29 | HOMEDIR = Path("~").expanduser() | ||
| 29 | 30 | ||
| 30 | 31 | ||
| 31 | # These are the release keys we sign with. | 32 | # These are the release keys we sign with. |
| @@ -54,7 +55,7 @@ def run(opts, cmd, check=True, **kwargs): | |||
| 54 | def import_release_key(opts): | 55 | def import_release_key(opts): |
| 55 | """Import the public key of the official release repo signing key.""" | 56 | """Import the public key of the official release repo signing key.""" |
| 56 | # Extract the key from our repo launcher. | 57 | # Extract the key from our repo launcher. |
| 57 | launcher = getattr(opts, "launcher", os.path.join(TOPDIR, "repo")) | 58 | launcher = getattr(opts, "launcher", TOPDIR / "repo") |
| 58 | print(f'Importing keys from "{launcher}" launcher script') | 59 | print(f'Importing keys from "{launcher}" launcher script') |
| 59 | with open(launcher, encoding="utf-8") as fp: | 60 | with open(launcher, encoding="utf-8") as fp: |
| 60 | data = fp.read() | 61 | data = fp.read() |
| @@ -102,6 +102,15 @@ def run_isort(): | |||
| 102 | ).returncode | 102 | ).returncode |
| 103 | 103 | ||
| 104 | 104 | ||
| 105 | def run_check_metadata(): | ||
| 106 | """Returns the exit code from check-metadata.""" | ||
| 107 | return subprocess.run( | ||
| 108 | [sys.executable, "release/check-metadata.py"], | ||
| 109 | check=False, | ||
| 110 | cwd=ROOT_DIR, | ||
| 111 | ).returncode | ||
| 112 | |||
| 113 | |||
| 105 | def run_update_manpages() -> int: | 114 | def run_update_manpages() -> int: |
| 106 | """Returns the exit code from release/update-manpages.""" | 115 | """Returns the exit code from release/update-manpages.""" |
| 107 | # Allow this to fail on CI, but not local devs. | 116 | # Allow this to fail on CI, but not local devs. |
| @@ -124,6 +133,7 @@ def main(argv): | |||
| 124 | run_black, | 133 | run_black, |
| 125 | run_flake8, | 134 | run_flake8, |
| 126 | run_isort, | 135 | run_isort, |
| 136 | run_check_metadata, | ||
| 127 | run_update_manpages, | 137 | run_update_manpages, |
| 128 | ) | 138 | ) |
| 129 | # Run all the tests all the time to get full feedback. Don't exit on the | 139 | # Run all the tests all the time to get full feedback. Don't exit on the |
