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