xref: /aosp_15_r20/external/grpc-grpc/tools/distrib/check_copyright.py (revision cc02d7e222339f7a4f6ba5f422e6413f4bd931f2)
1#!/usr/bin/env python3
2
3# Copyright 2015 gRPC authors.
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9#     http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16
17import argparse
18import datetime
19import os
20import re
21import subprocess
22import sys
23
24# find our home
25ROOT = os.path.abspath(os.path.join(os.path.dirname(sys.argv[0]), "../.."))
26os.chdir(ROOT)
27
28# parse command line
29argp = argparse.ArgumentParser(description="copyright checker")
30argp.add_argument(
31    "-o", "--output", default="details", choices=["list", "details"]
32)
33argp.add_argument("-s", "--skips", default=0, action="store_const", const=1)
34argp.add_argument("-a", "--ancient", default=0, action="store_const", const=1)
35argp.add_argument("--precommit", action="store_true")
36argp.add_argument("--fix", action="store_true")
37args = argp.parse_args()
38
39# open the license text
40with open("NOTICE.txt") as f:
41    LICENSE_NOTICE = f.read().splitlines()
42
43# license format by file extension
44# key is the file extension, value is a format string
45# that given a line of license text, returns what should
46# be in the file
47LICENSE_PREFIX_RE = {
48    ".bat": r"@rem\s*",
49    ".c": r"\s*(?://|\*)\s*",
50    ".cc": r"\s*(?://|\*)\s*",
51    ".h": r"\s*(?://|\*)\s*",
52    ".m": r"\s*\*\s*",
53    ".mm": r"\s*\*\s*",
54    ".php": r"\s*\*\s*",
55    ".js": r"\s*\*\s*",
56    ".py": r"#\s*",
57    ".pyx": r"#\s*",
58    ".pxd": r"#\s*",
59    ".pxi": r"#\s*",
60    ".rb": r"#\s*",
61    ".sh": r"#\s*",
62    ".proto": r"//\s*",
63    ".cs": r"//\s*",
64    ".mak": r"#\s*",
65    ".bazel": r"#\s*",
66    ".bzl": r"#\s*",
67    "Makefile": r"#\s*",
68    "Dockerfile": r"#\s*",
69    "BUILD": r"#\s*",
70}
71
72# The key is the file extension, while the value is a tuple of fields
73# (header, prefix, footer).
74# For example, for javascript multi-line comments, the header will be '/*', the
75# prefix will be '*' and the footer will be '*/'.
76# If header and footer are irrelevant for a specific file extension, they are
77# set to None.
78LICENSE_PREFIX_TEXT = {
79    ".bat": (None, "@rem", None),
80    ".c": (None, "//", None),
81    ".cc": (None, "//", None),
82    ".h": (None, "//", None),
83    ".m": ("/**", " *", " */"),
84    ".mm": ("/**", " *", " */"),
85    ".php": ("/**", " *", " */"),
86    ".js": ("/**", " *", " */"),
87    ".py": (None, "#", None),
88    ".pyx": (None, "#", None),
89    ".pxd": (None, "#", None),
90    ".pxi": (None, "#", None),
91    ".rb": (None, "#", None),
92    ".sh": (None, "#", None),
93    ".proto": (None, "//", None),
94    ".cs": (None, "//", None),
95    ".mak": (None, "#", None),
96    ".bazel": (None, "#", None),
97    ".bzl": (None, "#", None),
98    "Makefile": (None, "#", None),
99    "Dockerfile": (None, "#", None),
100    "BUILD": (None, "#", None),
101}
102
103_EXEMPT = frozenset(
104    (
105        # Generated protocol compiler output.
106        "examples/python/helloworld/helloworld_pb2.py",
107        "examples/python/helloworld/helloworld_pb2_grpc.py",
108        "examples/python/multiplex/helloworld_pb2.py",
109        "examples/python/multiplex/helloworld_pb2_grpc.py",
110        "examples/python/multiplex/route_guide_pb2.py",
111        "examples/python/multiplex/route_guide_pb2_grpc.py",
112        "examples/python/route_guide/route_guide_pb2.py",
113        "examples/python/route_guide/route_guide_pb2_grpc.py",
114        # Generated doxygen config file
115        "tools/doxygen/Doxyfile.php",
116        # An older file originally from outside gRPC.
117        "src/php/tests/bootstrap.php",
118        # census.proto copied from github
119        "tools/grpcz/census.proto",
120        # status.proto copied from googleapis
121        "src/proto/grpc/status/status.proto",
122        # Gradle wrappers used to build for Android
123        "examples/android/helloworld/gradlew.bat",
124        "src/android/test/interop/gradlew.bat",
125        # Designer-generated source
126        "examples/csharp/HelloworldXamarin/Droid/Resources/Resource.designer.cs",
127        "examples/csharp/HelloworldXamarin/iOS/ViewController.designer.cs",
128        # BoringSSL generated header. It has commit version information at the head
129        # of the file so we cannot check the license info.
130        "src/boringssl/boringssl_prefix_symbols.h",
131    )
132)
133
134_ENFORCE_CPP_STYLE_COMMENT_PATH_PREFIX = tuple(
135    [
136        "include/grpc++/",
137        "include/grpcpp/",
138        "src/core/",
139        "src/cpp/",
140        "test/core/",
141        "test/cpp/",
142        "fuzztest/",
143    ]
144)
145
146RE_YEAR = (
147    r"Copyright (?P<first_year>[0-9]+\-)?(?P<last_year>[0-9]+) ([Tt]he )?gRPC"
148    r" [Aa]uthors(\.|)"
149)
150RE_LICENSE = dict(
151    (
152        k,
153        r"\n".join(
154            LICENSE_PREFIX_RE[k]
155            + (RE_YEAR if re.search(RE_YEAR, line) else re.escape(line))
156            for line in LICENSE_NOTICE
157        ),
158    )
159    for k, v in list(LICENSE_PREFIX_RE.items())
160)
161
162RE_C_STYLE_COMMENT_START = r"^/\*\s*\n"
163RE_C_STYLE_COMMENT_OPTIONAL_LINE = r"(?:\s*\*\s*\n)*"
164RE_C_STYLE_COMMENT_END = r"\s*\*/"
165RE_C_STYLE_COMMENT_LICENSE = (
166    RE_C_STYLE_COMMENT_START
167    + RE_C_STYLE_COMMENT_OPTIONAL_LINE
168    + r"\n".join(
169        r"\s*(?:\*)\s*"
170        + (RE_YEAR if re.search(RE_YEAR, line) else re.escape(line))
171        for line in LICENSE_NOTICE
172    )
173    + r"\n"
174    + RE_C_STYLE_COMMENT_OPTIONAL_LINE
175    + RE_C_STYLE_COMMENT_END
176)
177RE_CPP_STYLE_COMMENT_LICENSE = r"\n".join(
178    r"\s*(?://)\s*" + (RE_YEAR if re.search(RE_YEAR, line) else re.escape(line))
179    for line in LICENSE_NOTICE
180)
181
182YEAR = datetime.datetime.now().year
183
184LICENSE_YEAR = f"Copyright {YEAR} gRPC authors."
185
186
187def join_license_text(header, prefix, footer, notice):
188    text = (header + "\n") if header else ""
189
190    def add_prefix(prefix, line):
191        # Don't put whitespace between prefix and empty line to avoid having
192        # trailing whitespaces.
193        return prefix + ("" if len(line) == 0 else " ") + line
194
195    text += "\n".join(
196        add_prefix(prefix, (LICENSE_YEAR if re.search(RE_YEAR, line) else line))
197        for line in LICENSE_NOTICE
198    )
199    text += "\n"
200    if footer:
201        text += footer + "\n"
202    return text
203
204
205LICENSE_TEXT = dict(
206    (
207        k,
208        join_license_text(
209            LICENSE_PREFIX_TEXT[k][0],
210            LICENSE_PREFIX_TEXT[k][1],
211            LICENSE_PREFIX_TEXT[k][2],
212            LICENSE_NOTICE,
213        ),
214    )
215    for k, v in list(LICENSE_PREFIX_TEXT.items())
216)
217
218if args.precommit:
219    FILE_LIST_COMMAND = (
220        "git status -z | grep -Poz '(?<=^[MARC][MARCD ] )[^\s]+'"
221    )
222else:
223    FILE_LIST_COMMAND = (
224        "git ls-tree -r --name-only -r HEAD | "
225        "grep -v ^third_party/ |"
226        'grep -v "\(ares_config.h\|ares_build.h\)"'
227    )
228
229
230def load(name):
231    with open(name) as f:
232        return f.read()
233
234
235def save(name, text):
236    with open(name, "w") as f:
237        f.write(text)
238
239
240assert re.search(RE_LICENSE["Makefile"], load("Makefile"))
241
242
243def log(cond, why, filename):
244    if not cond:
245        return
246    if args.output == "details":
247        print(("%s: %s" % (why, filename)))
248    else:
249        print(filename)
250
251
252def write_copyright(license_text, file_text, filename):
253    shebang = ""
254    lines = file_text.split("\n")
255    if lines and lines[0].startswith("#!"):
256        shebang = lines[0] + "\n"
257        file_text = file_text[len(shebang) :]
258
259    rewritten_text = shebang + license_text + "\n" + file_text
260    with open(filename, "w") as f:
261        f.write(rewritten_text)
262
263
264def replace_copyright(license_text, file_text, filename):
265    m = re.search(RE_C_STYLE_COMMENT_LICENSE, text)
266    if m:
267        rewritten_text = license_text + file_text[m.end() :]
268        with open(filename, "w") as f:
269            f.write(rewritten_text)
270        return True
271    return False
272
273
274# scan files, validate the text
275ok = True
276filename_list = []
277try:
278    filename_list = (
279        subprocess.check_output(FILE_LIST_COMMAND, shell=True)
280        .decode()
281        .splitlines()
282    )
283except subprocess.CalledProcessError:
284    sys.exit(0)
285
286for filename in filename_list:
287    enforce_cpp_style_comment = False
288    if filename in _EXEMPT:
289        continue
290    # Skip check for upb generated code.
291    if (
292        filename.endswith(".upb.h")
293        or filename.endswith(".upbdefs.h")
294        or filename.endswith(".upbdefs.c")
295        or filename.endswith(".upb_minitable.h")
296        or filename.endswith(".upb_minitable.c")
297    ):
298        continue
299    # Allow empty __init__.py files for code generated by xds_protos
300    if filename.startswith("tools/distrib/python/xds_protos") and (
301        filename.endswith("__init__.py")
302        or filename.endswith("generated_file_import_test.py")
303    ):
304        continue
305    ext = os.path.splitext(filename)[1]
306    base = os.path.basename(filename)
307    if filename.startswith(_ENFORCE_CPP_STYLE_COMMENT_PATH_PREFIX) and ext in [
308        ".cc",
309        ".h",
310    ]:
311        enforce_cpp_style_comment = True
312        re_license = RE_CPP_STYLE_COMMENT_LICENSE
313        license_text = LICENSE_TEXT[ext]
314    elif ext in RE_LICENSE:
315        re_license = RE_LICENSE[ext]
316        license_text = LICENSE_TEXT[ext]
317    elif base in RE_LICENSE:
318        re_license = RE_LICENSE[base]
319        license_text = LICENSE_TEXT[base]
320    else:
321        log(args.skips, "skip", filename)
322        continue
323    try:
324        text = load(filename)
325    except:
326        continue
327    m = re.search(re_license, text)
328    if m:
329        pass
330    elif enforce_cpp_style_comment:
331        log(
332            1,
333            "copyright missing or does not use cpp-style copyright header",
334            filename,
335        )
336        if args.fix:
337            # Attempt fix: search for c-style copyright header and replace it
338            # with cpp-style copyright header. If that doesn't work
339            # (e.g. missing copyright header), write cpp-style copyright header.
340            if not replace_copyright(license_text, text, filename):
341                write_copyright(license_text, text, filename)
342        ok = False
343    elif "DO NOT EDIT" not in text:
344        if args.fix:
345            write_copyright(license_text, text, filename)
346            log(1, "copyright missing (fixed)", filename)
347        else:
348            log(1, "copyright missing", filename)
349        ok = False
350
351if not ok and not args.fix:
352    print(
353        "You may use following command to automatically fix copyright headers:"
354    )
355    print("    tools/distrib/check_copyright.py --fix")
356
357sys.exit(0 if ok else 1)
358