xref: /aosp_15_r20/external/openscreen/tools/licenses.py (revision 3f982cf4871df8771c9d4abe6e9a6f8d829b2736)
1#!/usr/bin/env python3
2# Copyright 2020 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5"""Utility for checking and processing licensing information in third_party
6directories. Copied from Chrome's tools/licenses.py.
7
8Usage: licenses.py <command>
9
10Commands:
11  scan     scan third_party directories, verifying that we have licensing info
12  credits  generate about:credits on stdout
13
14(You can also import this as a module.)
15"""
16from __future__ import print_function
17
18import argparse
19import codecs
20import json
21import os
22import shutil
23import re
24import subprocess
25import sys
26import tempfile
27
28# TODO(issuetracker.google.com/173766869): Remove Python2 checks/compatibility.
29if sys.version_info.major == 2:
30    from cgi import escape
31else:
32    from html import escape
33
34_REPOSITORY_ROOT = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
35
36# Paths from the root of the tree to directories to skip.
37PRUNE_PATHS = set([
38    # Used for development and test, not in the shipping product.
39    os.path.join('third_party', 'llvm-build'),
40])
41
42# Directories we don't scan through.
43PRUNE_DIRS = ('.git')
44
45# Directories where we check out directly from upstream, and therefore
46# can't provide a README.chromium.  Please prefer a README.chromium
47# wherever possible.
48SPECIAL_CASES = {
49    os.path.join('third_party', 'googletest'): {
50        "Name": "gtest",
51        "URL": "http://code.google.com/p/googletest",
52        "License": "BSD",
53        "License File": "NOT_SHIPPED",
54    }
55}
56
57# Special value for 'License File' field used to indicate that the license file
58# should not be used in about:credits.
59NOT_SHIPPED = "NOT_SHIPPED"
60
61
62def MakeDirectory(dir_path):
63    try:
64        os.makedirs(dir_path)
65    except OSError:
66        pass
67
68
69def WriteDepfile(depfile_path, first_gn_output, inputs=None):
70    assert depfile_path != first_gn_output  # http://crbug.com/646165
71    assert not isinstance(inputs, string_types)  # Easy mistake to make
72    inputs = inputs or []
73    MakeDirectory(os.path.dirname(depfile_path))
74    # Ninja does not support multiple outputs in depfiles.
75    with open(depfile_path, 'w') as depfile:
76        depfile.write(first_gn_output.replace(' ', '\\ '))
77        depfile.write(': ')
78        depfile.write(' '.join(i.replace(' ', '\\ ') for i in inputs))
79        depfile.write('\n')
80
81
82class LicenseError(Exception):
83    """We raise this exception when a directory's licensing info isn't
84    fully filled out."""
85    pass
86
87
88def AbsolutePath(path, filename, root):
89    """Convert a path in README.chromium to be absolute based on the source
90    root."""
91    if filename.startswith('/'):
92        # Absolute-looking paths are relative to the source root
93        # (which is the directory we're run from).
94        absolute_path = os.path.join(root, filename[1:])
95    else:
96        absolute_path = os.path.join(root, path, filename)
97    if os.path.exists(absolute_path):
98        return absolute_path
99    return None
100
101
102def ParseDir(path, root, require_license_file=True, optional_keys=None):
103    """Examine a third_party/foo component and extract its metadata."""
104    # Parse metadata fields out of README.chromium.
105    # We examine "LICENSE" for the license file by default.
106    metadata = {
107        "License File": "LICENSE",  # Relative path to license text.
108        "Name": None,  # Short name (for header on about:credits).
109        "URL": None,  # Project home page.
110        "License": None,  # Software license.
111    }
112
113    if optional_keys is None:
114        optional_keys = []
115
116    if path in SPECIAL_CASES:
117        metadata.update(SPECIAL_CASES[path])
118    else:
119        # Try to find README.chromium.
120        readme_path = os.path.join(root, path, 'README.chromium')
121        if not os.path.exists(readme_path):
122            raise LicenseError("missing README.chromium or licenses.py "
123                               "SPECIAL_CASES entry in %s\n" % path)
124
125        for line in open(readme_path):
126            line = line.strip()
127            if not line:
128                break
129            for key in list(metadata.keys()) + optional_keys:
130                field = key + ": "
131                if line.startswith(field):
132                    metadata[key] = line[len(field):]
133
134    # Check that all expected metadata is present.
135    errors = []
136    for key, value in metadata.items():
137        if not value:
138            errors.append("couldn't find '" + key + "' line "
139                          "in README.chromium or licences.py "
140                          "SPECIAL_CASES")
141
142    # Special-case modules that aren't in the shipping product, so don't need
143    # their license in about:credits.
144    if metadata["License File"] != NOT_SHIPPED:
145        # Check that the license file exists.
146        for filename in (metadata["License File"], "COPYING"):
147            license_path = AbsolutePath(path, filename, root)
148            if license_path is not None:
149                break
150
151        if require_license_file and not license_path:
152            errors.append("License file not found. "
153                          "Either add a file named LICENSE, "
154                          "import upstream's COPYING if available, "
155                          "or add a 'License File:' line to "
156                          "README.chromium with the appropriate path.")
157        metadata["License File"] = license_path
158
159    if errors:
160        raise LicenseError("Errors in %s:\n %s\n" %
161                           (path, ";\n ".join(errors)))
162    return metadata
163
164
165def ContainsFiles(path, root):
166    """Determines whether any files exist in a directory or in any of its
167    subdirectories."""
168    for _, dirs, files in os.walk(os.path.join(root, path)):
169        if files:
170            return True
171        for prune_dir in PRUNE_DIRS:
172            if prune_dir in dirs:
173                dirs.remove(prune_dir)
174    return False
175
176
177def FilterDirsWithFiles(dirs_list, root):
178    # If a directory contains no files, assume it's a DEPS directory for a
179    # project not used by our current configuration and skip it.
180    return [x for x in dirs_list if ContainsFiles(x, root)]
181
182
183def FindThirdPartyDirs(prune_paths, root):
184    """Find all third_party directories underneath the source root."""
185    third_party_dirs = set()
186    for path, dirs, files in os.walk(root):
187        path = path[len(root) + 1:]  # Pretty up the path.
188
189        # .gitignore ignores /out*/, so do the same here.
190        if path in prune_paths or path.startswith('out'):
191            dirs[:] = []
192            continue
193
194        # Prune out directories we want to skip.
195        # (Note that we loop over PRUNE_DIRS so we're not iterating over a
196        # list that we're simultaneously mutating.)
197        for skip in PRUNE_DIRS:
198            if skip in dirs:
199                dirs.remove(skip)
200
201        if os.path.basename(path) == 'third_party':
202            # Add all subdirectories that are not marked for skipping.
203            for dir in dirs:
204                dirpath = os.path.join(path, dir)
205                if dirpath not in prune_paths:
206                    third_party_dirs.add(dirpath)
207
208            # Don't recurse into any subdirs from here.
209            dirs[:] = []
210            continue
211
212    return third_party_dirs
213
214
215def FindThirdPartyDirsWithFiles(root):
216    third_party_dirs = FindThirdPartyDirs(PRUNE_PATHS, root)
217    return FilterDirsWithFiles(third_party_dirs, root)
218
219
220# Many builders do not contain 'gn' in their PATH, so use the GN binary from
221# //buildtools.
222def _GnBinary():
223    exe = 'gn'
224    if sys.platform.startswith('linux'):
225        subdir = 'linux64'
226    elif sys.platform == 'darwin':
227        subdir = 'mac'
228    elif sys.platform == 'win32':
229        subdir, exe = 'win', 'gn.exe'
230    else:
231        raise RuntimeError("Unsupported platform '%s'." % sys.platform)
232
233    return os.path.join(_REPOSITORY_ROOT, 'buildtools', subdir, exe)
234
235
236def GetThirdPartyDepsFromGNDepsOutput(gn_deps, target_os):
237    """Returns third_party/foo directories given the output of "gn desc deps".
238
239    Note that it always returns the direct sub-directory of third_party
240    where README.chromium and LICENSE files are, so that it can be passed to
241    ParseDir(). e.g.:
242        third_party/cld_3/src/src/BUILD.gn -> third_party/cld_3
243
244    It returns relative paths from _REPOSITORY_ROOT, not absolute paths.
245    """
246    third_party_deps = set()
247    for absolute_build_dep in gn_deps.split():
248        relative_build_dep = os.path.relpath(absolute_build_dep,
249                                             _REPOSITORY_ROOT)
250        m = re.search(
251            r'^((.+[/\\])?third_party[/\\][^/\\]+[/\\])(.+[/\\])?BUILD\.gn$',
252            relative_build_dep)
253        if not m:
254            continue
255        third_party_path = m.group(1)
256        if any(third_party_path.startswith(p + os.sep) for p in PRUNE_PATHS):
257            continue
258        third_party_deps.add(third_party_path[:-1])
259    return third_party_deps
260
261
262def FindThirdPartyDeps(gn_out_dir, gn_target, target_os):
263    if not gn_out_dir:
264        raise RuntimeError("--gn-out-dir is required if --gn-target is used.")
265
266    # Generate gn project in temp directory and use it to find dependencies.
267    # Current gn directory cannot be used when we run this script in a gn action
268    # rule, because gn doesn't allow recursive invocations due to potential side
269    # effects.
270    tmp_dir = None
271    try:
272        tmp_dir = tempfile.mkdtemp(dir=gn_out_dir)
273        shutil.copy(os.path.join(gn_out_dir, "args.gn"), tmp_dir)
274        subprocess.check_output([_GnBinary(), "gen", tmp_dir])
275        gn_deps = subprocess.check_output([
276            _GnBinary(), "desc", tmp_dir, gn_target, "deps", "--as=buildfile",
277            "--all"
278        ])
279        if isinstance(gn_deps, bytes):
280            gn_deps = gn_deps.decode("utf-8")
281    finally:
282        if tmp_dir and os.path.exists(tmp_dir):
283            shutil.rmtree(tmp_dir)
284
285    return GetThirdPartyDepsFromGNDepsOutput(gn_deps, target_os)
286
287
288def ScanThirdPartyDirs(root=None):
289    """Scan a list of directories and report on any problems we find."""
290    if root is None:
291        root = os.getcwd()
292    third_party_dirs = FindThirdPartyDirsWithFiles(root)
293
294    errors = []
295    for path in sorted(third_party_dirs):
296        try:
297            metadata = ParseDir(path, root)
298        except LicenseError as e:
299            errors.append((path, e.args[0]))
300            continue
301
302    return ['{}: {}'.format(path, error) for path, error in sorted(errors)]
303
304
305def GenerateCredits(file_template_file,
306                    entry_template_file,
307                    output_file,
308                    target_os,
309                    gn_out_dir,
310                    gn_target,
311                    depfile=None):
312    """Generate about:credits."""
313
314    def EvaluateTemplate(template, env, escape=True):
315        """Expand a template with variables like {{foo}} using a
316        dictionary of expansions."""
317        for key, val in env.items():
318            if escape:
319                val = escape(val)
320            template = template.replace('{{%s}}' % key, val)
321        return template
322
323    def MetadataToTemplateEntry(metadata, entry_template):
324        env = {
325            'name': metadata['Name'],
326            'url': metadata['URL'],
327            'license': open(metadata['License File']).read(),
328        }
329        return {
330            'name': metadata['Name'],
331            'content': EvaluateTemplate(entry_template, env),
332            'license_file': metadata['License File'],
333        }
334
335    if gn_target:
336        third_party_dirs = FindThirdPartyDeps(gn_out_dir, gn_target, target_os)
337
338        # Sanity-check to raise a build error if invalid gn_... settings are
339        # somehow passed to this script.
340        if not third_party_dirs:
341            raise RuntimeError("No deps found.")
342    else:
343        third_party_dirs = FindThirdPartyDirs(PRUNE_PATHS, _REPOSITORY_ROOT)
344
345    if not file_template_file:
346        file_template_file = os.path.join(_REPOSITORY_ROOT, 'components',
347                                          'about_ui', 'resources',
348                                          'about_credits.tmpl')
349    if not entry_template_file:
350        entry_template_file = os.path.join(_REPOSITORY_ROOT, 'components',
351                                           'about_ui', 'resources',
352                                           'about_credits_entry.tmpl')
353
354    entry_template = open(entry_template_file).read()
355    entries = []
356    # Start from Chromium's LICENSE file
357    chromium_license_metadata = {
358        'Name': 'The Chromium Project',
359        'URL': 'http://www.chromium.org',
360        'License File': os.path.join(_REPOSITORY_ROOT, 'LICENSE')
361    }
362    entries.append(
363        MetadataToTemplateEntry(chromium_license_metadata, entry_template))
364
365    entries_by_name = {}
366    for path in third_party_dirs:
367        try:
368            metadata = ParseDir(path, _REPOSITORY_ROOT)
369        except LicenseError:
370            # TODO(phajdan.jr): Convert to fatal error (http://crbug.com/39240).
371            continue
372        if metadata['License File'] == NOT_SHIPPED:
373            continue
374
375        new_entry = MetadataToTemplateEntry(metadata, entry_template)
376        # Skip entries that we've already seen.
377        prev_entry = entries_by_name.setdefault(new_entry['name'], new_entry)
378        if prev_entry is not new_entry and (
379                prev_entry['content'] == new_entry['content']):
380            continue
381
382        entries.append(new_entry)
383
384    entries.sort(key=lambda entry: (entry['name'].lower(), entry['content']))
385    for entry_id, entry in enumerate(entries):
386        entry['content'] = entry['content'].replace('{{id}}', str(entry_id))
387
388    entries_contents = '\n'.join([entry['content'] for entry in entries])
389    file_template = open(file_template_file).read()
390    template_contents = "<!-- Generated by licenses.py; do not edit. -->"
391    template_contents += EvaluateTemplate(file_template,
392                                          {'entries': entries_contents},
393                                          escape=False)
394
395    if output_file:
396        changed = True
397        try:
398            old_output = open(output_file, 'r').read()
399            if old_output == template_contents:
400                changed = False
401        except:
402            pass
403        if changed:
404            with open(output_file, 'w') as output:
405                output.write(template_contents)
406    else:
407        print(template_contents)
408
409    if depfile:
410        assert output_file
411        # Add in build.ninja so that the target will be considered dirty when
412        # gn gen is run. Otherwise, it will fail to notice new files being
413        # added. This is still not perfect, as it will fail if no build files
414        # are changed, but a new README.chromium / LICENSE is added. This
415        # shouldn't happen in practice however.
416        license_file_list = (entry['license_file'] for entry in entries)
417        license_file_list = (os.path.relpath(p) for p in license_file_list)
418        license_file_list = sorted(set(license_file_list))
419        WriteDepfile(depfile, output_file, license_file_list + ['build.ninja'])
420
421    return True
422
423
424def _ReadFile(path):
425    """Reads a file from disk.
426    Args:
427      path: The path of the file to read, relative to the root of the
428      repository.
429    Returns:
430      The contents of the file as a string.
431    """
432    with codecs.open(os.path.join(_REPOSITORY_ROOT, path), 'r', 'utf-8') as f:
433        return f.read()
434
435
436def GenerateLicenseFile(output_file, gn_out_dir, gn_target, target_os):
437    """Generate a plain-text LICENSE file which can be used when you ship a part
438    of Chromium code (specified by gn_target) as a stand-alone library
439    (e.g., //ios/web_view).
440
441    The LICENSE file contains licenses of both Chromium and third-party
442    libraries which gn_target depends on. """
443
444    third_party_dirs = FindThirdPartyDeps(gn_out_dir, gn_target, target_os)
445
446    # Start with Chromium's LICENSE file.
447    content = [_ReadFile('LICENSE')]
448
449    # Add necessary third_party.
450    for directory in sorted(third_party_dirs):
451        metadata = ParseDir(directory,
452                            _REPOSITORY_ROOT,
453                            require_license_file=True)
454        license_file = metadata['License File']
455        if license_file and license_file != NOT_SHIPPED:
456            content.append('-' * 20)
457            content.append(directory.split(os.sep)[-1])
458            content.append('-' * 20)
459            content.append(_ReadFile(license_file))
460
461    content_text = '\n'.join(content)
462
463    if output_file:
464        with codecs.open(output_file, 'w', 'utf-8') as output:
465            output.write(content_text)
466    else:
467        print(content_text)
468
469
470def main():
471    parser = argparse.ArgumentParser()
472    parser.add_argument('--file-template',
473                        help='Template HTML to use for the license page.')
474    parser.add_argument('--entry-template',
475                        help='Template HTML to use for each license.')
476    parser.add_argument('--target-os', help='OS that this build is targeting.')
477    parser.add_argument('--gn-out-dir',
478                        help='GN output directory for scanning dependencies.')
479    parser.add_argument('--gn-target',
480                        help='GN target to scan for dependencies.')
481    parser.add_argument('command',
482                        choices=['help', 'scan', 'credits', 'license_file'])
483    parser.add_argument('output_file', nargs='?')
484    parser.add_argument('--depfile',
485                        help='Path to depfile (refer to `gn help depfile`)')
486    args = parser.parse_args()
487
488    if args.command == 'scan':
489        if not ScanThirdPartyDirs():
490            return 1
491    elif args.command == 'credits':
492        if not GenerateCredits(args.file_template, args.entry_template,
493                               args.output_file, args.target_os,
494                               args.gn_out_dir, args.gn_target, args.depfile):
495            return 1
496    elif args.command == 'license_file':
497        try:
498            GenerateLicenseFile(args.output_file, args.gn_out_dir,
499                                args.gn_target, args.target_os)
500        except LicenseError as e:
501            print("Failed to parse README.chromium: {}".format(e))
502            return 1
503    else:
504        print(__doc__)
505        return 1
506
507
508if __name__ == '__main__':
509    sys.exit(main())
510