df318ccf4f
and some of the typing issues found by mypy
215 lines
9.5 KiB
Python
Executable file
215 lines
9.5 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
# Copyright 2020 Google LLC
|
|
#
|
|
# Licensed under the the Apache License v2.0 with LLVM Exceptions (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# https://llvm.org/LICENSE.txt
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Compute the LLVM_ENABLE_PROJECTS for cmake from diff.
|
|
|
|
This script will compute which projects are affected by the diff provided via STDIN.
|
|
It gets the modified files in the patch, assigns them to projects and based on a
|
|
project dependency graph it will get the transitively affected projects.
|
|
"""
|
|
|
|
import argparse
|
|
import logging
|
|
import os
|
|
import platform
|
|
import sys
|
|
from typing import Any, Dict, List, Set, TextIO, Tuple, Optional, Union
|
|
from unidiff import PatchSet # type: ignore
|
|
import yaml
|
|
|
|
class ChooseProjects:
|
|
# file where dependencies are defined
|
|
SCRIPT_DIR = os.path.dirname(__file__)
|
|
DEPENDENCIES_FILE = os.path.join(SCRIPT_DIR, 'llvm-dependencies.yaml')
|
|
|
|
def __init__(self, llvm_dir: Optional[str]):
|
|
self.llvm_dir = llvm_dir
|
|
self.defaultProjects: Dict[str, Dict[str, str]] = {}
|
|
# List of projects this project depends on, transitive closure.
|
|
# E.g. compiler-rt -> [llvm, clang].
|
|
self.dependencies: Dict[str,Set[str]] = {}
|
|
# List of projects that depends on this project. It's a full closure.
|
|
# E.g. llvm -> [clang, libcxx, ...]
|
|
self.usages: Dict[str, Set[str]] = dict()
|
|
self.all_projects: List[str] = ['all']
|
|
self.config: Dict[str, Any] = {}
|
|
self._load_config()
|
|
|
|
def _load_config(self):
|
|
logging.info('loading project config from {}'.format(self.DEPENDENCIES_FILE))
|
|
with open(self.DEPENDENCIES_FILE) as dependencies_file:
|
|
self.config = yaml.load(dependencies_file, Loader=yaml.SafeLoader)
|
|
for k, v in self.config['dependencies'].items():
|
|
self.dependencies[k] = set(v)
|
|
# Closure of dependencies.
|
|
while True:
|
|
updated = False
|
|
for s in self.dependencies.values():
|
|
n = len(s)
|
|
extend = set()
|
|
for d in s:
|
|
extend.update(self.dependencies.get(d, set()))
|
|
s.update(extend)
|
|
if len(s) > n:
|
|
updated = True
|
|
if not updated:
|
|
break
|
|
# Usages don't need to be closed as dependencies already are.
|
|
for project, deps in self.dependencies.items():
|
|
for d in deps:
|
|
self.usages.setdefault(d, set()).add(project)
|
|
logging.info(f'computed dependencies: {self.dependencies}')
|
|
logging.info(f'computed usages: {self.usages}')
|
|
self.all_projects = self.config['allprojects'].keys()
|
|
|
|
def get_excluded(self, os: str) -> Set[str]:
|
|
"""Returns transitive closure for excluded projects"""
|
|
return self.get_affected_projects(set(self.config['excludedProjects'].get(os, [])))
|
|
|
|
def get_check_targets(self, projects: Set[str]) -> Set[str]:
|
|
"""Return the `check-xxx` targets to pass to ninja for the given list of projects"""
|
|
if 'all' in projects:
|
|
return set(["check-all"])
|
|
targets = set()
|
|
all_projects = self.config['allprojects']
|
|
for project in projects:
|
|
targets.update(set(all_projects.get(project, [])))
|
|
return targets
|
|
|
|
@staticmethod
|
|
def _detect_os() -> str:
|
|
"""Detect the current operating system."""
|
|
if platform.system() == 'Windows':
|
|
return 'windows'
|
|
return 'linux'
|
|
|
|
def choose_projects(self, patch: str = None, os_name: Optional[str] = None) -> List[str]:
|
|
"""List all touched project with all projects that they depend on and also
|
|
all projects that depend on them"""
|
|
if self.llvm_dir is None:
|
|
raise ValueError('path to llvm folder must be set in ChooseProject.')
|
|
llvm_dir = os.path.abspath(os.path.expanduser(self.llvm_dir))
|
|
logging.info('Scanning LLVM in {}'.format(llvm_dir))
|
|
if not self.match_projects_dirs():
|
|
logging.warning(f'{llvm_dir} does not look like a llvm-project directory')
|
|
return self.get_all_enabled_projects(os_name)
|
|
changed_files = self.get_changed_files(patch)
|
|
changed_projects, unmapped_changes = self.get_changed_projects(changed_files)
|
|
if unmapped_changes:
|
|
logging.warning('There were changes that could not be mapped to a project.'
|
|
'Building all projects instead!')
|
|
return self.get_all_enabled_projects(os_name)
|
|
return self.extend_projects(changed_projects, os_name)
|
|
|
|
def extend_projects(self, projects: Set[str], os_name : Optional[str] = None) -> List[str]:
|
|
"""Given a set of projects returns a set of projects to be tested taking
|
|
in account exclusions from llvm-dependencies.yaml.
|
|
"""
|
|
logging.info(f'projects: {projects}')
|
|
if not os_name:
|
|
os_name = self._detect_os()
|
|
# Find all affected by current set.
|
|
affected_projects = self.get_affected_projects(projects)
|
|
logging.info(f'all affected projects(*) {affected_projects}')
|
|
# Exclude everything that is affected by excluded.
|
|
excluded_projects = self.get_excluded(os_name)
|
|
logging.info(f'all excluded projects(*) {excluded_projects}')
|
|
affected_projects = affected_projects - excluded_projects
|
|
logging.info(f'effective projects list {affected_projects}')
|
|
return sorted(affected_projects)
|
|
|
|
def run(self):
|
|
affected_projects = self.choose_projects()
|
|
print("Affected:", ';'.join(affected_projects))
|
|
print("Dependencies:", ';'.join(self.get_dependencies(affected_projects)))
|
|
print("Check targets:", ';'.join(self.get_check_targets(affected_projects)))
|
|
return 0
|
|
|
|
def match_projects_dirs(self) -> bool:
|
|
"""Make sure that all projects are folders in the LLVM dir.
|
|
"""
|
|
subdirs = os.listdir(self.llvm_dir)
|
|
for project in self.all_projects:
|
|
if project not in subdirs:
|
|
logging.error('Project not found in LLVM root folder: {}'.format(project))
|
|
return False
|
|
return True
|
|
|
|
@staticmethod
|
|
def get_changed_files(patch_str: Union[str, TextIO, None] = None) -> Set[str]:
|
|
"""get list of changed files from the patch or from STDIN.
|
|
e.g. ['compiler-rt/lib/tsan/CMakeLists.txt']"""
|
|
if patch_str is None:
|
|
patch_str = sys.stdin
|
|
patch = PatchSet(patch_str)
|
|
|
|
changed_files = set({f.path for f in patch.modified_files + patch.added_files + patch.removed_files})
|
|
|
|
logging.info('Files modified by this patch:\n ' + '\n '.join(sorted(changed_files)))
|
|
return changed_files
|
|
|
|
def get_changed_projects(self, changed_files: Set[str]) -> Tuple[Set[str], bool]:
|
|
"""Get list of projects affected by the change."""
|
|
logging.info("Get list of projects affected by the change.")
|
|
changed_projects = set()
|
|
unmapped_changes = False
|
|
for changed_file in changed_files:
|
|
project = changed_file.split('/', maxsplit=1)
|
|
# There is no utils project.
|
|
if project[0] == 'utils':
|
|
continue
|
|
if (project is None) or (project[0] not in self.all_projects):
|
|
unmapped_changes = True
|
|
logging.warning('Could not map file to project: {}'.format(changed_file))
|
|
else:
|
|
changed_projects.add(project[0])
|
|
|
|
logging.info('Projects directly modified by this patch:\n ' + '\n '.join(sorted(changed_projects)))
|
|
return changed_projects, unmapped_changes
|
|
|
|
def get_affected_projects(self, changed_projects: Set[str]) -> Set[str]:
|
|
"""Compute transitive closure of affected projects based on the
|
|
dependencies between the projects (including initially passed)."""
|
|
affected: Set[str] = set(changed_projects)
|
|
for p in changed_projects:
|
|
affected.update(self.usages.get(p, set()))
|
|
logging.info(f'added {affected - changed_projects} projects as they are affected')
|
|
return affected
|
|
|
|
def get_dependencies(self, projects: Set[str]) -> Set[str]:
|
|
"""Return transitive dependencies for a given projects (including the projects themself).
|
|
|
|
These are the required dependencies for given `projects` so that they can be built.
|
|
"""
|
|
affected: Set[str] = set(projects)
|
|
for p in projects:
|
|
affected.update(self.dependencies.get(p, set()))
|
|
return affected
|
|
|
|
def get_all_enabled_projects(self, os_name: Optional[str] = None) -> List[str]:
|
|
"""Get list of all not-excluded projects for current platform."""
|
|
return self.extend_projects(set(self.all_projects), os_name)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(
|
|
description='Compute the projects affected by a change. A patch file is expected on stdin.')
|
|
parser.add_argument('--llvmdir', type=str, default='.')
|
|
parser.add_argument('--log-level', type=str, default='INFO')
|
|
args = parser.parse_args()
|
|
logging.basicConfig(level=args.log_level, format='%(levelname)-7s %(message)s')
|
|
logging.info(f'checking changes in {args.llvmdir}')
|
|
chooser = ChooseProjects(args.llvmdir)
|
|
sys.exit(chooser.run())
|