| #!/usr/bin/env python3 |
| |
| # Copyright 2018 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| """A script used to manage Google Maven dependencies for Chromium. |
| |
| For each dependency in `build.gradle`: |
| |
| - Download the library |
| - Download the LICENSE |
| - Generate a README.chromium file |
| - Generate a GN target in BUILD.gn |
| - Generate .info files for AAR libraries |
| - Generate a 'deps' entry in DEPS. |
| """ |
| |
| import argparse |
| import collections |
| import concurrent.futures |
| import contextlib |
| import fnmatch |
| import logging |
| import tempfile |
| import textwrap |
| import os |
| import re |
| import shutil |
| import subprocess |
| import urllib.request |
| import zipfile |
| |
| # Assume this script is stored under third_party/android_deps/ |
| _CHROMIUM_SRC = os.path.normpath(os.path.join(__file__, '..', '..', '..')) |
| |
| # Default android_deps directory. |
| _PRIMARY_ANDROID_DEPS_DIR = os.path.join(_CHROMIUM_SRC, 'third_party', |
| 'android_deps') |
| |
| # Path to additional_readme_paths.json relative to custom 'android_deps' directory. |
| _ADDITIONAL_README_PATHS = 'additional_readme_paths.json' |
| |
| # Path to BUILD.gn file from custom 'android_deps' directory. |
| _BUILD_GN = 'BUILD.gn' |
| |
| # Path to build.gradle file relative to custom 'android_deps' directory. |
| _BUILD_GRADLE = 'build.gradle' |
| |
| # Location of the android_deps libs directory relative to custom 'android_deps' directory. |
| _LIBS_DIR = 'libs' |
| |
| _GN_PATH = os.path.join(_CHROMIUM_SRC, 'third_party', 'depot_tools', 'gn') |
| |
| _GRADLEW = os.path.join(_CHROMIUM_SRC, 'third_party', 'gradle_wrapper', |
| 'gradlew') |
| |
| # Git-controlled files needed by, but not updated by this tool. |
| # Relative to _PRIMARY_ANDROID_DEPS_DIR. |
| _PRIMARY_ANDROID_DEPS_FILES = [ |
| 'buildSrc', |
| 'licenses', |
| 'settings.gradle.template', |
| 'vulnerability_supressions.xml', |
| ] |
| |
| # Git-controlled files needed by and updated by this tool. |
| # Relative to args.android_deps_dir. |
| _CUSTOM_ANDROID_DEPS_FILES = [ |
| os.path.join('..', '..', 'DEPS'), |
| _BUILD_GN, |
| _ADDITIONAL_README_PATHS, |
| 'subprojects.txt', |
| ] |
| |
| # Dictionary mapping long info file names to shorter ones to avoid paths being |
| # over 200 chars. This must match the dictionary in BuildConfigGenerator.groovy. |
| _REDUCED_ID_LENGTH_MAP = { |
| 'com_google_android_apps_common_testing_accessibility_framework_accessibility_test_framework': |
| 'com_google_android_accessibility_test_framework', |
| } |
| |
| # If this file exists in an aar file then it is appended to LICENSE |
| _THIRD_PARTY_LICENSE_FILENAME = 'third_party_licenses.txt' |
| |
| # Path to the aar.py script used to generate .info files. |
| _AAR_PY = os.path.join(_CHROMIUM_SRC, 'build', 'android', 'gyp', 'aar.py') |
| |
| |
| @contextlib.contextmanager |
| def BuildDir(dirname=None): |
| """Helper function used to manage a build directory. |
| |
| Args: |
| dirname: Optional build directory path. If not provided, a temporary |
| directory will be created and cleaned up on exit. |
| Returns: |
| A python context manager modelling a directory path. The manager |
| removes the directory if necessary on exit. |
| """ |
| delete = False |
| if not dirname: |
| dirname = tempfile.mkdtemp() |
| delete = True |
| try: |
| yield dirname |
| finally: |
| if delete: |
| shutil.rmtree(dirname) |
| |
| |
| def RaiseCommandException(args, returncode, output, error): |
| """Raise an exception whose message describing a command failure. |
| |
| Args: |
| args: shell command-line (as passed to subprocess.call()) |
| returncode: status code. |
| error: standard error output. |
| Raises: |
| a new Exception. |
| """ |
| message = 'Command failed with status {}: {}\n'.format(returncode, args) |
| if output: |
| message += 'Output:-----------------------------------------\n{}\n' \ |
| '------------------------------------------------\n'.format(output) |
| if error: |
| message += 'Error message: ---------------------------------\n{}\n' \ |
| '------------------------------------------------\n'.format(error) |
| raise Exception(message) |
| |
| |
| def RunCommand(args, print_stdout=False, cwd=None): |
| """Run a new shell command. |
| |
| This function runs without printing anything. |
| |
| Args: |
| args: A string or a list of strings for the shell command. |
| Raises: |
| On failure, raise an Exception that contains the command's arguments, |
| return status, and standard output + error merged in a single message. |
| """ |
| logging.debug('Run %s', args) |
| stdout = None if print_stdout else subprocess.PIPE |
| p = subprocess.Popen(args, stdout=stdout, cwd=cwd) |
| pout, _ = p.communicate() |
| if p.returncode != 0: |
| RaiseCommandException(args, p.returncode, None, pout) |
| |
| |
| def RunCommandAndGetOutput(args): |
| """Run a new shell command. Return its output. Exception on failure. |
| |
| This function runs without printing anything. |
| |
| Args: |
| args: A string or a list of strings for the shell command. |
| Returns: |
| The command's output. |
| Raises: |
| On failure, raise an Exception that contains the command's arguments, |
| return status, and standard output, and standard error as separate |
| messages. |
| """ |
| logging.debug('Run %s', args) |
| p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
| pout, perr = p.communicate() |
| if p.returncode != 0: |
| RaiseCommandException(args, p.returncode, pout, perr) |
| return pout |
| |
| |
| def MakeDirectory(dir_path): |
| """Make directory |dir_path| recursively if necessary.""" |
| if dir_path != '' and not os.path.isdir(dir_path): |
| logging.debug('mkdir [%s]', dir_path) |
| os.makedirs(dir_path) |
| |
| |
| def DeleteDirectory(dir_path): |
| """Recursively delete a directory if it exists.""" |
| if os.path.exists(dir_path): |
| logging.debug('rmdir [%s]', dir_path) |
| shutil.rmtree(dir_path) |
| |
| |
| def Copy(src_dir, src_paths, dst_dir, dst_paths, src_path_must_exist=True): |
| """Copies |src_paths| in |src_dir| to |dst_paths| in |dst_dir|. |
| |
| Args: |
| src_dir: Directory containing |src_paths|. |
| src_paths: Files to copy. |
| dst_dir: Directory containing |dst_paths|. |
| dst_paths: Copy destinations. |
| src_paths_must_exist: If False, do not throw error if the file for one of |
| |src_paths| does not exist. |
| """ |
| assert len(src_paths) == len(dst_paths) |
| |
| missing_files = [] |
| for src_path, dst_path in zip(src_paths, dst_paths): |
| abs_src_path = os.path.join(src_dir, src_path) |
| abs_dst_path = os.path.join(dst_dir, dst_path) |
| if os.path.exists(abs_src_path): |
| CopyFileOrDirectory(abs_src_path, abs_dst_path) |
| elif src_path_must_exist: |
| missing_files.append(src_path) |
| |
| if missing_files: |
| raise Exception('Missing files from {}: {}'.format( |
| src_dir, missing_files)) |
| |
| |
| def CopyFileOrDirectory(src_path, dst_path, ignore_extension=None): |
| """Copy file or directory |src_path| into |dst_path| exactly. |
| |
| Args: |
| src_path: Source path. |
| dst_path: Destination path. |
| ignore_extension: File extension of files not to copy, starting with '.'. If None, all files |
| are copied. |
| """ |
| assert not ignore_extension or ignore_extension[0] == '.' |
| |
| src_path = os.path.normpath(src_path) |
| dst_path = os.path.normpath(dst_path) |
| logging.debug('copy [%s -> %s]', src_path, dst_path) |
| MakeDirectory(os.path.dirname(dst_path)) |
| if os.path.isdir(src_path): |
| # Copy directory recursively. |
| DeleteDirectory(dst_path) |
| ignore = None |
| if ignore_extension: |
| ignore = shutil.ignore_patterns('*' + ignore_extension) |
| shutil.copytree(src_path, dst_path, ignore=ignore) |
| elif not ignore_extension or not re.match('.*\.' + ignore_extension[1:], |
| src_path): |
| shutil.copy(src_path, dst_path) |
| |
| |
| def ReadFile(file_path): |
| """Read a file, return its content.""" |
| with open(file_path) as f: |
| return f.read() |
| |
| |
| def ReadFileAsLines(file_path): |
| """Read a file as a series of lines.""" |
| with open(file_path) as f: |
| return f.readlines() |
| |
| |
| def WriteFile(file_path, file_data): |
| """Write a file.""" |
| if isinstance(file_data, str): |
| file_data = file_data.encode('utf8') |
| MakeDirectory(os.path.dirname(file_path)) |
| with open(file_path, 'wb') as f: |
| f.write(file_data) |
| |
| |
| def FindInDirectory(directory, filename_filter): |
| """Find all files in a directory that matches a given filename filter.""" |
| files = [] |
| for root, _dirnames, filenames in os.walk(directory): |
| matched_files = fnmatch.filter(filenames, filename_filter) |
| files.extend((os.path.join(root, f) for f in matched_files)) |
| return files |
| |
| |
| # Named tuple describing a CIPD package. |
| # - path: Path to cipd.yaml file. |
| # - name: cipd package name. |
| # - tag: cipd tag. |
| CipdPackageInfo = collections.namedtuple('CipdPackageInfo', |
| ['path', 'name', 'tag']) |
| |
| # Regular expressions used to extract useful info from cipd.yaml files |
| # generated by Gradle. See BuildConfigGenerator.groovy:makeCipdYaml() |
| _RE_CIPD_CREATE = re.compile('cipd create --pkg-def cipd.yaml -tag (\S*)') |
| _RE_CIPD_PACKAGE = re.compile('package: (\S*)') |
| |
| |
| def _ParseSubprojects(subproject_path): |
| """Parses listing of subproject build.gradle files. Returns list of paths.""" |
| if not os.path.exists(subproject_path): |
| return None |
| |
| subprojects = [] |
| for subproject in open(subproject_path): |
| subproject = subproject.strip() |
| if subproject and not subproject.startswith('#'): |
| subprojects.append(subproject) |
| return subprojects |
| |
| |
| def _GenerateSettingsGradle(subproject_dirs, settings_template_path, |
| settings_out_path): |
| """Generates settings file by replacing "{{subproject_dirs}}" string in template. |
| |
| Args: |
| subproject_dirs: List of subproject directories to substitute into template. |
| settings_template_path: Path of template file to substitute into. |
| settings_out_path: Path of output settings.gradle file. |
| """ |
| with open(settings_template_path) as f: |
| template_content = f.read() |
| |
| subproject_dirs_str = '' |
| if subproject_dirs: |
| subproject_dirs_str = '\'' + '\',\''.join(subproject_dirs) + '\'' |
| |
| template_content = template_content.replace('{{subproject_dirs}}', |
| subproject_dirs_str) |
| with open(settings_out_path, 'w') as f: |
| f.write(template_content) |
| |
| |
| def _BuildGradleCmd(build_android_deps_dir, task): |
| return [ |
| _GRADLEW, '-p', build_android_deps_dir, '--stacktrace', |
| '--warning-mode', 'all', task |
| ] |
| |
| |
| def _CheckVulnerabilities(build_android_deps_dir, report_dst): |
| logging.warning('Running Gradle dependencyCheckAnalyze. This may take a ' |
| 'few minutes the first time.') |
| |
| # Separate command from main gradle command so that we can provide specific |
| # diagnostics in case of failure of this step. |
| gradle_cmd = _BuildGradleCmd(build_android_deps_dir, |
| 'dependencyCheckAnalyze') |
| |
| report_src = os.path.join(build_android_deps_dir, 'build', 'reports') |
| if os.path.exists(report_dst): |
| shutil.rmtree(report_dst) |
| |
| try: |
| logging.info('CMD: %s', ' '.join(gradle_cmd)) |
| subprocess.run(gradle_cmd, check=True) |
| except subprocess.CalledProcessError: |
| report_path = os.path.join(report_dst, 'dependency-check-report.html') |
| logging.error( |
| textwrap.dedent(""" |
| ============================================================================= |
| A package has a known vulnerability. It may not be in a package or packages |
| which you just added, but you need to resolve the problem before proceeding. |
| If you can't easily fix it by rolling the package to a fixed version now, |
| please file a crbug of type= Bug-Security providing all relevant information, |
| and then rerun this command with --ignore-vulnerabilities. |
| The html version of the report is avialable at: {} |
| ============================================================================= |
| """.format(report_path))) |
| raise |
| finally: |
| if os.path.exists(report_src): |
| CopyFileOrDirectory(report_src, report_dst) |
| |
| |
| def _ReduceNameLength(path_str): |
| """Returns a shorter path string if needed. |
| |
| Args: |
| path_str: A String representing the path. |
| Returns: |
| A String (possibly shortened) of that path. |
| """ |
| return _REDUCED_ID_LENGTH_MAP.get(path_str, path_str) |
| |
| |
| def GetCipdPackageInfo(cipd_yaml_path): |
| """Returns the CIPD package name corresponding to a given cipd.yaml file. |
| |
| Args: |
| cipd_yaml_path: Path of input cipd.yaml file. |
| Returns: |
| A (package_name, package_tag) tuple. |
| Raises: |
| Exception if the file could not be read. |
| """ |
| package_name = None |
| package_tag = None |
| for line in ReadFileAsLines(cipd_yaml_path): |
| m = _RE_CIPD_PACKAGE.match(line) |
| if m: |
| package_name = m.group(1) |
| |
| m = _RE_CIPD_CREATE.search(line) |
| if m: |
| package_tag = m.group(1) |
| |
| if not package_name or not package_tag: |
| raise Exception('Invalid cipd.yaml format: ' + cipd_yaml_path) |
| |
| return (package_name, package_tag) |
| |
| |
| def ParseDeps(root_dir, libs_dir): |
| """Parse an android_deps/libs and retrieve package information. |
| |
| Args: |
| root_dir: Path to a root Chromium or build directory. |
| Returns: |
| A directory mapping package names to tuples of |
| (cipd_yaml_file, package_name, package_tag), where |cipd_yaml_file| |
| is the path to the cipd.yaml file, related to |libs_dir|, |
| and |package_name| and |package_tag| are the extracted from it. |
| """ |
| result = {} |
| root_dir = os.path.abspath(root_dir) |
| libs_dir = os.path.abspath(os.path.join(root_dir, libs_dir)) |
| for cipd_file in FindInDirectory(libs_dir, 'cipd.yaml'): |
| pkg_name, pkg_tag = GetCipdPackageInfo(cipd_file) |
| cipd_path = os.path.dirname(cipd_file) |
| cipd_path = cipd_path[len(root_dir) + 1:] |
| result[pkg_name] = CipdPackageInfo(cipd_path, pkg_name, pkg_tag) |
| |
| return result |
| |
| |
| def PrintPackageList(packages, list_name): |
| """Print a list of packages to standard output. |
| |
| Args: |
| packages: list of package names. |
| list_name: a simple word describing the package list (e.g. 'new') |
| """ |
| print(' {} {} packages:'.format(len(packages), list_name)) |
| print('\n'.join(' - ' + p for p in packages)) |
| |
| |
| def _DownloadOverrides(overrides, build_libs_dir): |
| for spec in overrides: |
| subpath, url = spec.split(':', 1) |
| target_path = os.path.join(build_libs_dir, subpath) |
| if not os.path.isfile(target_path): |
| found_files = 'Found instead:\n' + '\n'.join( |
| FindInDirectory(os.path.dirname(target_path), '*')) |
| raise Exception( |
| f'Override path does not exist: {target_path}\n{found_files}') |
| logging.info('Fetching override for %s', target_path) |
| with urllib.request.urlopen(url) as response: |
| with open(target_path, 'wb') as f: |
| shutil.copyfileobj(response, f) |
| |
| |
| def _CreateAarInfos(aar_files): |
| jobs = [] |
| |
| for aar_file in aar_files: |
| aar_dirname = os.path.dirname(aar_file) |
| aar_info_name = _ReduceNameLength( |
| os.path.basename(aar_dirname)) + '.info' |
| aar_info_path = os.path.join(aar_dirname, aar_info_name) |
| |
| logging.debug('- %s', aar_info_name) |
| cmd = [_AAR_PY, 'list', aar_file, '--output', aar_info_path] |
| |
| if aar_info_name == 'com_google_android_material_material.info': |
| # Keep in sync with copy in BuildConfigGenerator.groovy. |
| resource_exclusion_glbos = [ |
| 'res/layout*/*calendar*', |
| 'res/layout*/*chip_input*', |
| 'res/layout*/*clock*', |
| 'res/layout*/*picker*', |
| 'res/layout*/*time*', |
| ] |
| cmd += [ |
| '--resource-exclusion-globs', |
| repr(resource_exclusion_glbos).replace("'", '"') |
| ] |
| proc = subprocess.Popen(cmd) |
| jobs.append((cmd, proc)) |
| |
| for cmd, proc in jobs: |
| if proc.wait(): |
| raise Exception('Command Failed: {}\n'.format(' '.join(cmd))) |
| |
| |
| def main(): |
| parser = argparse.ArgumentParser( |
| description=__doc__, |
| formatter_class=argparse.RawDescriptionHelpFormatter) |
| parser.add_argument( |
| '--android-deps-dir', |
| help='Path to directory containing build.gradle from chromium-dir.', |
| default=_PRIMARY_ANDROID_DEPS_DIR) |
| parser.add_argument( |
| '--build-dir', |
| help='Path to build directory (default is temporary directory).') |
| parser.add_argument('--ignore-licenses', |
| help='Ignores licenses for these deps.', |
| action='store_true') |
| parser.add_argument('--ignore-vulnerabilities', |
| help='Ignores vulnerabilities for these deps.', |
| action='store_true') |
| parser.add_argument('--override-artifact', |
| action='append', |
| help='lib_subpath:url of .aar / .jar to override.') |
| parser.add_argument('-v', |
| '--verbose', |
| dest='verbose_count', |
| default=0, |
| action='count', |
| help='Verbose level (multiple times for more)') |
| args = parser.parse_args() |
| |
| logging.basicConfig( |
| level=logging.WARNING - 10 * args.verbose_count, |
| format='%(levelname).1s %(relativeCreated)6d %(message)s') |
| debug = args.verbose_count >= 2 |
| |
| if not os.path.isfile(os.path.join(args.android_deps_dir, _BUILD_GRADLE)): |
| raise Exception('--android-deps-dir {} does not contain {}.'.format( |
| args.android_deps_dir, _BUILD_GRADLE)) |
| is_primary_android_deps = args.android_deps_dir == _PRIMARY_ANDROID_DEPS_DIR |
| android_deps_subdir = os.path.relpath(args.android_deps_dir, _CHROMIUM_SRC) |
| |
| with BuildDir(args.build_dir) as build_dir: |
| build_android_deps_dir = os.path.join(build_dir, android_deps_subdir) |
| |
| logging.info('Using build directory: %s', build_dir) |
| Copy(_PRIMARY_ANDROID_DEPS_DIR, _PRIMARY_ANDROID_DEPS_FILES, |
| build_android_deps_dir, _PRIMARY_ANDROID_DEPS_FILES) |
| Copy(args.android_deps_dir, [_BUILD_GRADLE], build_android_deps_dir, |
| [_BUILD_GRADLE]) |
| Copy(args.android_deps_dir, |
| _CUSTOM_ANDROID_DEPS_FILES, |
| build_android_deps_dir, |
| _CUSTOM_ANDROID_DEPS_FILES, |
| src_path_must_exist=is_primary_android_deps) |
| |
| subprojects = _ParseSubprojects( |
| os.path.join(args.android_deps_dir, 'subprojects.txt')) |
| subproject_dirs = [] |
| if subprojects: |
| for (index, subproject) in enumerate(subprojects): |
| subproject_dir = 'subproject{}'.format(index) |
| Copy(args.android_deps_dir, [subproject], |
| build_android_deps_dir, |
| [os.path.join(subproject_dir, 'build.gradle')]) |
| subproject_dirs.append(subproject_dir) |
| |
| _GenerateSettingsGradle( |
| subproject_dirs, |
| os.path.join(_PRIMARY_ANDROID_DEPS_DIR, |
| 'settings.gradle.template'), |
| os.path.join(build_android_deps_dir, 'settings.gradle')) |
| |
| if not args.ignore_vulnerabilities: |
| report_dst = os.path.join(args.android_deps_dir, |
| 'vulnerability_reports') |
| _CheckVulnerabilities(build_android_deps_dir, report_dst) |
| |
| logging.info('Running Gradle.') |
| |
| # This gradle command generates the new DEPS and BUILD.gn files, it can |
| # also handle special cases. |
| # Edit BuildConfigGenerator.groovy#addSpecialTreatment for such cases. |
| gradle_cmd = _BuildGradleCmd(build_android_deps_dir, 'setupRepository') |
| if debug: |
| gradle_cmd.append('--debug') |
| if args.ignore_licenses: |
| gradle_cmd.append('-PskipLicenses=true') |
| |
| subprocess.run(gradle_cmd, check=True) |
| |
| logging.info('# Reformat %s.', |
| os.path.join(args.android_deps_dir, _BUILD_GN)) |
| gn_args = [ |
| os.path.relpath(_GN_PATH, _CHROMIUM_SRC), 'format', |
| os.path.join(os.path.relpath(build_android_deps_dir, _CHROMIUM_SRC), |
| _BUILD_GN) |
| ] |
| RunCommand(gn_args, print_stdout=debug, cwd=_CHROMIUM_SRC) |
| |
| build_libs_dir = os.path.join(build_android_deps_dir, _LIBS_DIR) |
| if args.override_artifact: |
| _DownloadOverrides(args.override_artifact, build_libs_dir) |
| aar_files = FindInDirectory(build_libs_dir, '*.aar') |
| |
| logging.info('# Generate Android .aar info files.') |
| _CreateAarInfos(aar_files) |
| |
| if not args.ignore_licenses: |
| logging.info('# Looking for nested license files.') |
| for aar_file in aar_files: |
| # Play Services .aar files have embedded licenses. |
| with zipfile.ZipFile(aar_file) as z: |
| if _THIRD_PARTY_LICENSE_FILENAME in z.namelist(): |
| aar_dirname = os.path.dirname(aar_file) |
| license_path = os.path.join(aar_dirname, 'LICENSE') |
| # Make sure to append as we don't want to lose the |
| # existing license. |
| with open(license_path, 'ab') as f: |
| f.write(z.read(_THIRD_PARTY_LICENSE_FILENAME)) |
| |
| logging.info('# Compare CIPD packages.') |
| existing_packages = ParseDeps(args.android_deps_dir, _LIBS_DIR) |
| build_packages = ParseDeps(build_android_deps_dir, _LIBS_DIR) |
| |
| deleted_packages = [] |
| updated_packages = [] |
| for pkg in sorted(existing_packages): |
| if pkg not in build_packages: |
| deleted_packages.append(pkg) |
| else: |
| existing_info = existing_packages[pkg] |
| build_info = build_packages[pkg] |
| if existing_info.tag != build_info.tag: |
| updated_packages.append(pkg) |
| |
| new_packages = sorted(set(build_packages) - set(existing_packages)) |
| |
| # Copy updated DEPS and BUILD.gn to build directory. |
| Copy(build_android_deps_dir, |
| _CUSTOM_ANDROID_DEPS_FILES, |
| args.android_deps_dir, |
| _CUSTOM_ANDROID_DEPS_FILES, |
| src_path_must_exist=is_primary_android_deps) |
| |
| # Delete obsolete or updated package directories. |
| for pkg in existing_packages.values(): |
| pkg_path = os.path.join(args.android_deps_dir, pkg.path) |
| DeleteDirectory(pkg_path) |
| |
| # Copy new and updated packages from build directory. |
| for pkg in build_packages.values(): |
| pkg_path = pkg.path |
| dst_pkg_path = os.path.join(args.android_deps_dir, pkg_path) |
| src_pkg_path = os.path.join(build_android_deps_dir, pkg_path) |
| CopyFileOrDirectory(src_pkg_path, |
| dst_pkg_path, |
| ignore_extension=".tmp") |
| |
| # Useful for printing timestamp. |
| logging.info('All Done.') |
| |
| if new_packages: |
| PrintPackageList(new_packages, 'new') |
| if updated_packages: |
| PrintPackageList(updated_packages, 'updated') |
| if deleted_packages: |
| PrintPackageList(deleted_packages, 'deleted') |
| |
| |
| if __name__ == "__main__": |
| main() |